mirror of
https://github.com/Kozea/Radicale.git
synced 2025-06-26 16:45:52 +00:00
resolved conflicts
This commit is contained in:
commit
50140a54f5
27 changed files with 383 additions and 80 deletions
6
.github/workflows/pypi-publish.yml
vendored
6
.github/workflows/pypi-publish.yml
vendored
|
@ -11,10 +11,10 @@ jobs:
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
- name: Install dependencies
|
- name: Install Build dependencies
|
||||||
run: python -m pip install wheel
|
run: pip install build
|
||||||
- name: Build
|
- name: Build
|
||||||
run: python setup.py sdist bdist_wheel
|
run: python -m build --sdist --wheel
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
uses: pypa/gh-action-pypi-publish@master
|
uses: pypa/gh-action-pypi-publish@master
|
||||||
with:
|
with:
|
||||||
|
|
32
.github/workflows/test.yml
vendored
32
.github/workflows/test.yml
vendored
|
@ -3,28 +3,36 @@ on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
python-version: [3.5, 3.6, 3.7, 3.8, pypy3]
|
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11', pypy-3.7, pypy-3.8, pypy-3.9]
|
||||||
|
exclude:
|
||||||
|
- os: windows-latest
|
||||||
|
python-version: pypy-3.7
|
||||||
|
- os: windows-latest
|
||||||
|
python-version: pypy-3.8
|
||||||
|
- os: windows-latest
|
||||||
|
python-version: pypy-3.9
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install from source
|
- name: Install Test dependencies
|
||||||
run: python -m pip install --editable .[test,bcrypt]
|
run: pip install tox
|
||||||
- name: Run tests
|
- name: Test
|
||||||
run: python setup.py test
|
run: tox
|
||||||
|
- name: Install Coveralls
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
run: pip install coveralls
|
||||||
- name: Upload coverage to Coveralls
|
- name: Upload coverage to Coveralls
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
env:
|
env:
|
||||||
COVERALLS_PARALLEL: true
|
COVERALLS_PARALLEL: true
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: coveralls --service=github
|
||||||
python -m pip install coveralls
|
|
||||||
python -m coveralls
|
|
||||||
|
|
||||||
coveralls-finish:
|
coveralls-finish:
|
||||||
needs: test
|
needs: test
|
||||||
|
@ -34,9 +42,9 @@ jobs:
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v2
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
|
- name: Install Coveralls
|
||||||
|
run: pip install coveralls
|
||||||
- name: Finish Coveralls parallel builds
|
- name: Finish Coveralls parallel builds
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: coveralls --service=github --finish
|
||||||
python -m pip install coveralls
|
|
||||||
python -m coveralls --finish
|
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -15,6 +15,7 @@ coverage.xml
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
.eggs
|
.eggs
|
||||||
|
.mypy_cache
|
||||||
.project
|
.project
|
||||||
.pydevproject
|
.pydevproject
|
||||||
.settings
|
.settings
|
||||||
|
|
4
.mdl.style
Normal file
4
.mdl.style
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
all
|
||||||
|
rule 'MD026', :punctuation => '.,;:!'
|
||||||
|
exclude_rule 'MD001'
|
||||||
|
exclude_rule 'MD024'
|
1
.mdlrc
Normal file
1
.mdlrc
Normal file
|
@ -0,0 +1 @@
|
||||||
|
style File.join(File.dirname(__FILE__), '.mdl.style')
|
|
@ -328,9 +328,13 @@ start the **Radicale** service.
|
||||||
|
|
||||||
### Reverse Proxy
|
### Reverse Proxy
|
||||||
|
|
||||||
When a reverse proxy is used, the path at which Radicale is available must
|
When a reverse proxy is used, and Radicale should be made available at a path
|
||||||
be provided via the `X-Script-Name` header. The proxy must remove the location
|
below the root (such as `/radicale/`), then this path must be provided via
|
||||||
from the URL path that is forwarded to Radicale.
|
the `X-Script-Name` header (without a trailing `/`). The proxy must remove
|
||||||
|
the location from the URL path that is forwarded to Radicale. If Radicale
|
||||||
|
should be made available at the root of the web server (in the nginx case
|
||||||
|
using `location /`), then the setting of the `X-Script-Name` header should be
|
||||||
|
removed from the example below.
|
||||||
|
|
||||||
Example **nginx** configuration:
|
Example **nginx** configuration:
|
||||||
|
|
||||||
|
@ -344,6 +348,20 @@ location /radicale/ { # The trailing / is important!
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Example **Caddy** configuration with basicauth from Caddy:
|
||||||
|
|
||||||
|
```Caddy
|
||||||
|
handle_path /radicale* {
|
||||||
|
basicauth {
|
||||||
|
user hash
|
||||||
|
}
|
||||||
|
reverse_proxy localhost:5232 {
|
||||||
|
header_up +X-Script-Name "/radicale"
|
||||||
|
header_up +X-remote-user "{http.auth.user.id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Example **Apache** configuration:
|
Example **Apache** configuration:
|
||||||
|
|
||||||
```apache
|
```apache
|
||||||
|
@ -354,6 +372,11 @@ RewriteRule ^/radicale$ /radicale/ [R,L]
|
||||||
ProxyPass http://localhost:5232/ retry=0
|
ProxyPass http://localhost:5232/ retry=0
|
||||||
ProxyPassReverse http://localhost:5232/
|
ProxyPassReverse http://localhost:5232/
|
||||||
RequestHeader set X-Script-Name /radicale
|
RequestHeader set X-Script-Name /radicale
|
||||||
|
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
|
||||||
|
RequestHeader unset X-Forwarded-Proto
|
||||||
|
<If "%{HTTPS} =~ /on/">
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
</If>
|
||||||
</Location>
|
</Location>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -366,6 +389,28 @@ RewriteRule ^(.*)$ http://localhost:5232/$1 [P,L]
|
||||||
|
|
||||||
# Set to directory of .htaccess file:
|
# Set to directory of .htaccess file:
|
||||||
RequestHeader set X-Script-Name /radicale
|
RequestHeader set X-Script-Name /radicale
|
||||||
|
RequestHeader set X-Forwarded-Port "%{SERVER_PORT}s"
|
||||||
|
RequestHeader unset X-Forwarded-Proto
|
||||||
|
<If "%{HTTPS} =~ /on/">
|
||||||
|
RequestHeader set X-Forwarded-Proto "https"
|
||||||
|
</If>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example **lighttpd** configuration:
|
||||||
|
|
||||||
|
```lighttpd
|
||||||
|
server.modules += ( "mod_proxy" , "mod_setenv", "mod_rewrite" )
|
||||||
|
|
||||||
|
$HTTP["url"] =~ "^/radicale/" {
|
||||||
|
proxy.server = ( "" => (( "host" => "127.0.0.1", "port" => "5232" )) )
|
||||||
|
proxy.header = ( "map-urlpath" => ( "/radicale/" => "/" ))
|
||||||
|
|
||||||
|
setenv.add-request-header = (
|
||||||
|
"X-Script-Name" => "/radicale",
|
||||||
|
"Script-Name" => "/radicale",
|
||||||
|
)
|
||||||
|
url.rewrite-once = ( "^/radicale/radicale/(.*)" => "/radicale/$1" )
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Be reminded that Radicale's default configuration enforces limits on the
|
Be reminded that Radicale's default configuration enforces limits on the
|
||||||
|
@ -458,6 +503,15 @@ key = /path/to/server_key.pem
|
||||||
certificate_authority = /path/to/client_cert.pem
|
certificate_authority = /path/to/client_cert.pem
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you're using the Let's Encrypt's Certbot, the configuration should look similar to this:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[server]
|
||||||
|
ssl = True
|
||||||
|
certificate = /etc/letsencrypt/live/{Your Domain}/fullchain.pem
|
||||||
|
key = /etc/letsencrypt/live/{Your Domain}/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
Example **nginx** configuration:
|
Example **nginx** configuration:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
|
@ -528,6 +582,16 @@ git add -A && (git diff --cached --quiet || git commit -m "Changes by "%(user)s)
|
||||||
The command gets executed after every change to the storage and commits
|
The command gets executed after every change to the storage and commits
|
||||||
the changes into the **git** repository.
|
the changes into the **git** repository.
|
||||||
|
|
||||||
|
For the hook to not cause errors either **git** user details need to be set and match the owner of the collections directory or the repository needs to be marked as safe.
|
||||||
|
|
||||||
|
When using the systemd unit file from the [Running as a service](#running-as-a-service) section this **cannot** be done via a `.gitconfig` file in the users home directory, as Radicale won't have read permissions!
|
||||||
|
|
||||||
|
In `/var/lib/radicale/collections/.git` run:
|
||||||
|
```bash
|
||||||
|
git config user.name "radicale"
|
||||||
|
git config user.email "radicale@example.com"
|
||||||
|
```
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
@ -855,7 +919,7 @@ RabbitMQ topic to publish message.
|
||||||
|
|
||||||
Default:
|
Default:
|
||||||
|
|
||||||
#### rabbitmq_topic
|
#### rabbitmq_queue_type
|
||||||
|
|
||||||
RabbitMQ queue type for the topic.
|
RabbitMQ queue type for the topic.
|
||||||
|
|
||||||
|
@ -1007,7 +1071,7 @@ An example rights file:
|
||||||
[root]
|
[root]
|
||||||
user: .+
|
user: .+
|
||||||
collection:
|
collection:
|
||||||
permissions: R
|
permissions: r
|
||||||
|
|
||||||
# Allow reading and writing principal collection (same as username)
|
# Allow reading and writing principal collection (same as username)
|
||||||
[principal]
|
[principal]
|
||||||
|
|
28
Dockerfile
28
Dockerfile
|
@ -1,17 +1,33 @@
|
||||||
# This file is intended to be used apart from the containing source code tree.
|
# This file is intended to be used apart from the containing source code tree.
|
||||||
|
|
||||||
FROM python:3-alpine
|
FROM python:3-alpine as builder
|
||||||
|
|
||||||
# Version of Radicale (e.g. v3)
|
# Version of Radicale (e.g. v3)
|
||||||
ARG VERSION=master
|
ARG VERSION=master
|
||||||
|
|
||||||
|
# Optional dependencies (e.g. bcrypt)
|
||||||
|
ARG DEPENDENCIES=bcrypt
|
||||||
|
|
||||||
|
RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \
|
||||||
|
&& python -m venv /app/venv \
|
||||||
|
&& /app/venv/bin/pip install --no-cache-dir "Radicale[${DEPENDENCIES}] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz"
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password \
|
||||||
|
&& apk add --no-cache ca-certificates openssl
|
||||||
|
|
||||||
|
COPY --chown=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
|
# TCP port of Radicale
|
||||||
EXPOSE 5232
|
EXPOSE 5232
|
||||||
# Run Radicale
|
# Run Radicale
|
||||||
CMD ["radicale", "--hosts", "0.0.0.0:5232"]
|
ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"]
|
||||||
|
CMD ["--hosts", "0.0.0.0:5232,[::]:5232"]
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates openssl \
|
USER radicale
|
||||||
&& apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \
|
|
||||||
&& pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" \
|
|
||||||
&& apk del .build-deps
|
|
||||||
|
|
31
Dockerfile.dev
Normal file
31
Dockerfile.dev
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
FROM python:3-alpine as builder
|
||||||
|
|
||||||
|
# Optional dependencies (e.g. bcrypt)
|
||||||
|
ARG DEPENDENCIES=bcrypt
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache --virtual gcc libffi-dev musl-dev \
|
||||||
|
&& python -m venv /app/venv \
|
||||||
|
&& /app/venv/bin/pip install --no-cache-dir .[${DEPENDENCIES}]
|
||||||
|
|
||||||
|
FROM python:3-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN adduser radicale --home /var/lib/radicale --system --uid 1000 --disabled-password \
|
||||||
|
&& apk add --no-cache ca-certificates openssl
|
||||||
|
|
||||||
|
COPY --chown=radicale --from=builder /app/venv /app
|
||||||
|
|
||||||
|
# Persistent storage for data
|
||||||
|
VOLUME /var/lib/radicale
|
||||||
|
# TCP port of Radicale
|
||||||
|
EXPOSE 5232
|
||||||
|
# Run Radicale
|
||||||
|
ENTRYPOINT [ "/app/bin/python", "/app/bin/radicale"]
|
||||||
|
CMD ["--hosts", "0.0.0.0:5232"]
|
||||||
|
|
||||||
|
USER radicale
|
|
@ -45,8 +45,8 @@ def propose_filename(collection: storage.BaseCollection) -> str:
|
||||||
|
|
||||||
class ApplicationPartGet(ApplicationBase):
|
class ApplicationPartGet(ApplicationBase):
|
||||||
|
|
||||||
def _content_disposition_attachement(self, filename: str) -> str:
|
def _content_disposition_attachment(self, filename: str) -> str:
|
||||||
value = "attachement"
|
value = "attachment"
|
||||||
try:
|
try:
|
||||||
encoded_filename = quote(filename, encoding=self._encoding)
|
encoded_filename = quote(filename, encoding=self._encoding)
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
|
@ -91,7 +91,7 @@ class ApplicationPartGet(ApplicationBase):
|
||||||
return (httputils.NOT_ALLOWED if limited_access else
|
return (httputils.NOT_ALLOWED if limited_access else
|
||||||
httputils.DIRECTORY_LISTING)
|
httputils.DIRECTORY_LISTING)
|
||||||
content_type = xmlutils.MIMETYPES[item.tag]
|
content_type = xmlutils.MIMETYPES[item.tag]
|
||||||
content_disposition = self._content_disposition_attachement(
|
content_disposition = self._content_disposition_attachment(
|
||||||
propose_filename(item))
|
propose_filename(item))
|
||||||
elif limited_access:
|
elif limited_access:
|
||||||
return httputils.NOT_ALLOWED
|
return httputils.NOT_ALLOWED
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
# along with Radicale. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import re
|
||||||
from http import client
|
from http import client
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
@ -26,6 +27,22 @@ from radicale.app.base import Access, ApplicationBase
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
||||||
|
|
||||||
|
def get_server_netloc(environ: types.WSGIEnviron, force_port: bool = False):
|
||||||
|
if environ.get("HTTP_X_FORWARDED_HOST"):
|
||||||
|
host = environ["HTTP_X_FORWARDED_HOST"]
|
||||||
|
proto = environ.get("HTTP_X_FORWARDED_PROTO") or "http"
|
||||||
|
port = "443" if proto == "https" else "80"
|
||||||
|
port = environ["HTTP_X_FORWARDED_PORT"] or port
|
||||||
|
else:
|
||||||
|
host = environ.get("HTTP_HOST") or environ["SERVER_NAME"]
|
||||||
|
proto = environ["wsgi.url_scheme"]
|
||||||
|
port = environ["SERVER_PORT"]
|
||||||
|
if (not force_port and port == ("443" if proto == "https" else "80") or
|
||||||
|
re.search(r":\d+$", host)):
|
||||||
|
return host
|
||||||
|
return host + ":" + port
|
||||||
|
|
||||||
|
|
||||||
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,
|
||||||
|
@ -33,7 +50,11 @@ class ApplicationPartMove(ApplicationBase):
|
||||||
"""Manage MOVE request."""
|
"""Manage MOVE request."""
|
||||||
raw_dest = environ.get("HTTP_DESTINATION", "")
|
raw_dest = environ.get("HTTP_DESTINATION", "")
|
||||||
to_url = urlparse(raw_dest)
|
to_url = urlparse(raw_dest)
|
||||||
if to_url.netloc != environ["HTTP_HOST"]:
|
to_netloc_with_port = to_url.netloc
|
||||||
|
if to_url.port is None:
|
||||||
|
to_netloc_with_port += (":443" if to_url.scheme == "https"
|
||||||
|
else ":80")
|
||||||
|
if to_netloc_with_port != get_server_netloc(environ, force_port=True):
|
||||||
logger.info("Unsupported destination address: %r", raw_dest)
|
logger.info("Unsupported destination address: %r", raw_dest)
|
||||||
# Remote destination server, not supported
|
# Remote destination server, not supported
|
||||||
return httputils.REMOTE_DESTINATION
|
return httputils.REMOTE_DESTINATION
|
||||||
|
|
|
@ -164,7 +164,7 @@ def check_and_sanitize_items(
|
||||||
ref_value_param = component.dtstart.params.get("VALUE")
|
ref_value_param = component.dtstart.params.get("VALUE")
|
||||||
for dates in chain(component.contents.get("exdate", []),
|
for dates in chain(component.contents.get("exdate", []),
|
||||||
component.contents.get("rdate", [])):
|
component.contents.get("rdate", [])):
|
||||||
if all(type(d) == type(ref_date) for d in dates.value):
|
if all(type(d) is type(ref_date) for d in dates.value):
|
||||||
continue
|
continue
|
||||||
for i, date in enumerate(dates.value):
|
for i, date in enumerate(dates.value):
|
||||||
dates.value[i] = ref_date.replace(
|
dates.value[i] = ref_date.replace(
|
||||||
|
|
|
@ -225,6 +225,7 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
||||||
def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
|
def get_children(components: Iterable[vobject.base.Component]) -> Iterator[
|
||||||
Tuple[vobject.base.Component, bool, List[date]]]:
|
Tuple[vobject.base.Component, bool, List[date]]]:
|
||||||
main = None
|
main = None
|
||||||
|
rec_main = None
|
||||||
recurrences = []
|
recurrences = []
|
||||||
for comp in components:
|
for comp in components:
|
||||||
if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
|
if hasattr(comp, "recurrence_id") and comp.recurrence_id.value:
|
||||||
|
@ -232,11 +233,14 @@ def visit_time_ranges(vobject_item: vobject.base.Component, child_name: str,
|
||||||
if comp.rruleset:
|
if comp.rruleset:
|
||||||
# Prevent possible infinite loop
|
# Prevent possible infinite loop
|
||||||
raise ValueError("Overwritten recurrence with RRULESET")
|
raise ValueError("Overwritten recurrence with RRULESET")
|
||||||
|
rec_main = comp
|
||||||
yield comp, True, []
|
yield comp, True, []
|
||||||
else:
|
else:
|
||||||
if main is not None:
|
if main is not None:
|
||||||
raise ValueError("Multiple main components")
|
raise ValueError("Multiple main components")
|
||||||
main = comp
|
main = comp
|
||||||
|
if main is None and len(recurrences) == 1:
|
||||||
|
main = rec_main
|
||||||
if main is None:
|
if main is None:
|
||||||
raise ValueError("Main component missing")
|
raise ValueError("Main component missing")
|
||||||
yield main, False, recurrences
|
yield main, False, recurrences
|
||||||
|
@ -468,7 +472,15 @@ def text_match(vobject_item: vobject.base.Component,
|
||||||
match(attrib) for child in children
|
match(attrib) for child in children
|
||||||
for attrib in child.params.get(attrib_name, []))
|
for attrib in child.params.get(attrib_name, []))
|
||||||
else:
|
else:
|
||||||
condition = any(match(child.value) for child in children)
|
res = []
|
||||||
|
for child in children:
|
||||||
|
# Some filters such as CATEGORIES provide a list in child.value
|
||||||
|
if type(child.value) is list:
|
||||||
|
for value in child.value:
|
||||||
|
res.append(match(value))
|
||||||
|
else:
|
||||||
|
res.append(match(child.value))
|
||||||
|
condition = any(res)
|
||||||
if filter_.get("negate-condition") == "yes":
|
if filter_.get("negate-condition") == "yes":
|
||||||
return not condition
|
return not condition
|
||||||
return condition
|
return condition
|
||||||
|
|
130
radicale/log.py
130
radicale/log.py
|
@ -25,16 +25,25 @@ Log messages are sent to the first available target of:
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Callable, ClassVar, Dict, Iterator, Union
|
import time
|
||||||
|
from typing import (Any, Callable, ClassVar, Dict, Iterator, Mapping, Optional,
|
||||||
|
Tuple, Union, cast)
|
||||||
|
|
||||||
from radicale import types
|
from radicale import types
|
||||||
|
|
||||||
LOGGER_NAME: str = "radicale"
|
LOGGER_NAME: str = "radicale"
|
||||||
LOGGER_FORMAT: str = "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s"
|
LOGGER_FORMATS: Mapping[str, str] = {
|
||||||
|
"verbose": "[%(asctime)s] [%(ident)s] [%(levelname)s] %(message)s",
|
||||||
|
"journal": "[%(ident)s] [%(levelname)s] %(message)s",
|
||||||
|
}
|
||||||
DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
|
DATE_FORMAT: str = "%Y-%m-%d %H:%M:%S %z"
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger(LOGGER_NAME)
|
logger: logging.Logger = logging.getLogger(LOGGER_NAME)
|
||||||
|
@ -59,12 +68,17 @@ class IdentLogRecordFactory:
|
||||||
|
|
||||||
def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord:
|
def __call__(self, *args: Any, **kwargs: Any) -> logging.LogRecord:
|
||||||
record = self._upstream_factory(*args, **kwargs)
|
record = self._upstream_factory(*args, **kwargs)
|
||||||
ident = "%d" % os.getpid()
|
ident = ("%d" % record.process if record.process is not None
|
||||||
main_thread = threading.main_thread()
|
else record.processName or "unknown")
|
||||||
current_thread = threading.current_thread()
|
tid = None
|
||||||
if current_thread.name and main_thread != current_thread:
|
if record.thread is not None:
|
||||||
ident += "/%s" % current_thread.name
|
if record.thread != threading.main_thread().ident:
|
||||||
|
ident += "/%s" % (record.threadName or "unknown")
|
||||||
|
if (sys.version_info >= (3, 8) and
|
||||||
|
record.thread == threading.get_ident()):
|
||||||
|
tid = threading.get_native_id()
|
||||||
record.ident = ident # type:ignore[attr-defined]
|
record.ident = ident # type:ignore[attr-defined]
|
||||||
|
record.tid = tid # type:ignore[attr-defined]
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@ -75,18 +89,101 @@ class ThreadedStreamHandler(logging.Handler):
|
||||||
terminator: ClassVar[str] = "\n"
|
terminator: ClassVar[str] = "\n"
|
||||||
|
|
||||||
_streams: Dict[int, types.ErrorStream]
|
_streams: Dict[int, types.ErrorStream]
|
||||||
|
_journal_stream_id: Optional[Tuple[int, int]]
|
||||||
|
_journal_socket: Optional[socket.socket]
|
||||||
|
_journal_socket_failed: bool
|
||||||
|
_formatters: Mapping[str, logging.Formatter]
|
||||||
|
_formatter: Optional[logging.Formatter]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, format_name: Optional[str] = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._streams = {}
|
self._streams = {}
|
||||||
|
self._journal_stream_id = None
|
||||||
|
with contextlib.suppress(TypeError, ValueError):
|
||||||
|
dev, inode = os.environ.get("JOURNAL_STREAM", "").split(":", 1)
|
||||||
|
self._journal_stream_id = (int(dev), int(inode))
|
||||||
|
self._journal_socket = None
|
||||||
|
self._journal_socket_failed = False
|
||||||
|
self._formatters = {name: logging.Formatter(fmt, DATE_FORMAT)
|
||||||
|
for name, fmt in LOGGER_FORMATS.items()}
|
||||||
|
self._formatter = (self._formatters[format_name]
|
||||||
|
if format_name is not None else None)
|
||||||
|
|
||||||
|
def _get_formatter(self, default_format_name: str) -> logging.Formatter:
|
||||||
|
return self._formatter or self._formatters[default_format_name]
|
||||||
|
|
||||||
|
def _detect_journal(self, stream: types.ErrorStream) -> bool:
|
||||||
|
if not self._journal_stream_id or not isinstance(stream, io.IOBase):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
stat = os.fstat(stream.fileno())
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return self._journal_stream_id == (stat.st_dev, stat.st_ino)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_journal(data: Mapping[str, Optional[Union[str, int]]]
|
||||||
|
) -> bytes:
|
||||||
|
msg = b""
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
keyb = key.encode()
|
||||||
|
valueb = str(value).encode()
|
||||||
|
if b"\n" in valueb:
|
||||||
|
msg += (keyb + b"\n" +
|
||||||
|
struct.pack("<Q", len(valueb)) + valueb + b"\n")
|
||||||
|
else:
|
||||||
|
msg += keyb + b"=" + valueb + b"\n"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
def _try_emit_journal(self, record: logging.LogRecord) -> bool:
|
||||||
|
if not self._journal_socket:
|
||||||
|
# Try to connect to systemd journal socket
|
||||||
|
if self._journal_socket_failed or not hasattr(socket, "AF_UNIX"):
|
||||||
|
return False
|
||||||
|
journal_socket = None
|
||||||
|
try:
|
||||||
|
journal_socket = socket.socket(
|
||||||
|
socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||||
|
journal_socket.connect("/run/systemd/journal/socket")
|
||||||
|
except OSError as e:
|
||||||
|
self._journal_socket_failed = True
|
||||||
|
if journal_socket:
|
||||||
|
journal_socket.close()
|
||||||
|
# Log after setting `_journal_socket_failed` to prevent loop!
|
||||||
|
logger.error("Failed to connect to systemd journal: %s",
|
||||||
|
e, exc_info=True)
|
||||||
|
return False
|
||||||
|
self._journal_socket = journal_socket
|
||||||
|
|
||||||
|
priority = {"DEBUG": 7,
|
||||||
|
"INFO": 6,
|
||||||
|
"WARNING": 4,
|
||||||
|
"ERROR": 3,
|
||||||
|
"CRITICAL": 2}.get(record.levelname, 4)
|
||||||
|
timestamp = time.strftime("%Y-%m-%dT%H:%M:%S.%%03dZ",
|
||||||
|
time.gmtime(record.created)) % record.msecs
|
||||||
|
data = {"PRIORITY": priority,
|
||||||
|
"TID": cast(Optional[int], getattr(record, "tid", None)),
|
||||||
|
"SYSLOG_IDENTIFIER": record.name,
|
||||||
|
"SYSLOG_FACILITY": 1,
|
||||||
|
"SYSLOG_PID": record.process,
|
||||||
|
"SYSLOG_TIMESTAMP": timestamp,
|
||||||
|
"CODE_FILE": record.pathname,
|
||||||
|
"CODE_LINE": record.lineno,
|
||||||
|
"CODE_FUNC": record.funcName,
|
||||||
|
"MESSAGE": self._get_formatter("journal").format(record)}
|
||||||
|
self._journal_socket.sendall(self._encode_journal(data))
|
||||||
|
return True
|
||||||
|
|
||||||
def emit(self, record: logging.LogRecord) -> None:
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
try:
|
try:
|
||||||
stream = self._streams.get(threading.get_ident(), sys.stderr)
|
stream = self._streams.get(threading.get_ident(), sys.stderr)
|
||||||
msg = self.format(record)
|
if self._detect_journal(stream) and self._try_emit_journal(record):
|
||||||
stream.write(msg)
|
return
|
||||||
stream.write(self.terminator)
|
msg = self._get_formatter("verbose").format(record)
|
||||||
if hasattr(stream, "flush"):
|
stream.write(msg + self.terminator)
|
||||||
stream.flush()
|
stream.flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.handleError(record)
|
self.handleError(record)
|
||||||
|
@ -111,13 +208,16 @@ def register_stream(stream: types.ErrorStream) -> Iterator[None]:
|
||||||
def setup() -> None:
|
def setup() -> None:
|
||||||
"""Set global logging up."""
|
"""Set global logging up."""
|
||||||
global register_stream
|
global register_stream
|
||||||
handler = ThreadedStreamHandler()
|
format_name = os.environ.get("RADICALE_LOG_FORMAT") or None
|
||||||
logging.basicConfig(format=LOGGER_FORMAT, datefmt=DATE_FORMAT,
|
sane_format_name = format_name if format_name in LOGGER_FORMATS else None
|
||||||
handlers=[handler])
|
handler = ThreadedStreamHandler(sane_format_name)
|
||||||
|
logging.basicConfig(handlers=[handler])
|
||||||
register_stream = handler.register_stream
|
register_stream = handler.register_stream
|
||||||
log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory())
|
log_record_factory = IdentLogRecordFactory(logging.getLogRecordFactory())
|
||||||
logging.setLogRecordFactory(log_record_factory)
|
logging.setLogRecordFactory(log_record_factory)
|
||||||
set_level(logging.WARNING)
|
set_level(logging.WARNING)
|
||||||
|
if format_name != sane_format_name:
|
||||||
|
logger.error("Invalid RADICALE_LOG_FORMAT: %r", format_name)
|
||||||
|
|
||||||
|
|
||||||
def set_level(level: Union[int, str]) -> None:
|
def set_level(level: Union[int, str]) -> None:
|
||||||
|
|
|
@ -58,11 +58,16 @@ elif sys.platform == "win32":
|
||||||
|
|
||||||
|
|
||||||
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
|
# IPv4 (host, port) and IPv6 (host, port, flowinfo, scopeid)
|
||||||
ADDRESS_TYPE = Union[Tuple[str, int], Tuple[str, int, int, int]]
|
ADDRESS_TYPE = Union[Tuple[Union[str, bytes, bytearray], int],
|
||||||
|
Tuple[str, int, int, int]]
|
||||||
|
|
||||||
|
|
||||||
def format_address(address: ADDRESS_TYPE) -> str:
|
def format_address(address: ADDRESS_TYPE) -> str:
|
||||||
return "[%s]:%d" % address[:2]
|
host, port, *_ = address
|
||||||
|
if not isinstance(host, str):
|
||||||
|
raise NotImplementedError("Unsupported address format: %r" %
|
||||||
|
(address,))
|
||||||
|
return "[%s]:%d" % (host, port)
|
||||||
|
|
||||||
|
|
||||||
class ParallelHTTPServer(socketserver.ThreadingMixIn,
|
class ParallelHTTPServer(socketserver.ThreadingMixIn,
|
||||||
|
|
|
@ -44,7 +44,8 @@ class CollectionBase(storage.BaseCollection):
|
||||||
filesystem_path = pathutils.path_to_filesystem(folder, self.path)
|
filesystem_path = pathutils.path_to_filesystem(folder, self.path)
|
||||||
self._filesystem_path = filesystem_path
|
self._filesystem_path = filesystem_path
|
||||||
|
|
||||||
@types.contextmanager
|
# TODO: better fix for "mypy"
|
||||||
|
@types.contextmanager # type: ignore
|
||||||
def _atomic_write(self, path: str, mode: str = "w",
|
def _atomic_write(self, path: str, mode: str = "w",
|
||||||
newline: Optional[str] = None) -> Iterator[IO[AnyStr]]:
|
newline: Optional[str] = None) -> Iterator[IO[AnyStr]]:
|
||||||
# TODO: Overload with Literal when dropping support for Python < 3.8
|
# TODO: Overload with Literal when dropping support for Python < 3.8
|
||||||
|
|
|
@ -86,7 +86,8 @@ class CollectionPartCache(CollectionBase):
|
||||||
content = self._item_cache_content(item)
|
content = self._item_cache_content(item)
|
||||||
self._storage._makedirs_synced(cache_folder)
|
self._storage._makedirs_synced(cache_folder)
|
||||||
# Race: Other processes might have created and locked the file.
|
# Race: Other processes might have created and locked the file.
|
||||||
with contextlib.suppress(PermissionError), self._atomic_write(
|
# TODO: better fix for "mypy"
|
||||||
|
with contextlib.suppress(PermissionError), self._atomic_write( # type: ignore
|
||||||
os.path.join(cache_folder, href), "wb") as fo:
|
os.path.join(cache_folder, href), "wb") as fo:
|
||||||
fb = cast(BinaryIO, fo)
|
fb = cast(BinaryIO, fo)
|
||||||
pickle.dump((cache_hash, *content), fb)
|
pickle.dump((cache_hash, *content), fb)
|
||||||
|
|
|
@ -61,6 +61,7 @@ class CollectionPartMeta(CollectionBase):
|
||||||
return self._meta_cache if key is None else self._meta_cache.get(key)
|
return self._meta_cache if key is None else self._meta_cache.get(key)
|
||||||
|
|
||||||
def set_meta(self, props: Mapping[str, str]) -> None:
|
def set_meta(self, props: Mapping[str, str]) -> None:
|
||||||
with self._atomic_write(self._props_path, "w") as fo:
|
# TODO: better fix for "mypy"
|
||||||
|
with self._atomic_write(self._props_path, "w") as fo: # type: ignore
|
||||||
f = cast(TextIO, fo)
|
f = cast(TextIO, fo)
|
||||||
json.dump(props, f, sort_keys=True)
|
json.dump(props, f, sort_keys=True)
|
||||||
|
|
|
@ -95,7 +95,8 @@ class CollectionPartSync(CollectionPartCache, CollectionPartHistory,
|
||||||
self._storage._makedirs_synced(token_folder)
|
self._storage._makedirs_synced(token_folder)
|
||||||
try:
|
try:
|
||||||
# Race: Other processes might have created and locked the file.
|
# Race: Other processes might have created and locked the file.
|
||||||
with self._atomic_write(token_path, "wb") as fo:
|
# TODO: better fix for "mypy"
|
||||||
|
with self._atomic_write(token_path, "wb") as fo: # type: ignore
|
||||||
fb = cast(BinaryIO, fo)
|
fb = cast(BinaryIO, fo)
|
||||||
pickle.dump(state, fb)
|
pickle.dump(state, fb)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
|
|
|
@ -43,7 +43,8 @@ class CollectionPartUpload(CollectionPartGet, CollectionPartCache,
|
||||||
raise ValueError("Failed to store item %r in collection %r: %s" %
|
raise ValueError("Failed to store item %r in collection %r: %s" %
|
||||||
(href, self.path, e)) from e
|
(href, self.path, e)) from e
|
||||||
path = pathutils.path_to_filesystem(self._filesystem_path, href)
|
path = pathutils.path_to_filesystem(self._filesystem_path, href)
|
||||||
with self._atomic_write(path, newline="") as fo:
|
# TODO: better fix for "mypy"
|
||||||
|
with self._atomic_write(path, newline="") as fo: # type: ignore
|
||||||
f = cast(TextIO, fo)
|
f = cast(TextIO, fo)
|
||||||
f.write(item.serialize())
|
f.write(item.serialize())
|
||||||
# Clean the cache after the actual item is stored, or the cache entry
|
# Clean the cache after the actual item is stored, or the cache entry
|
||||||
|
|
|
@ -25,6 +25,7 @@ import logging
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import wsgiref.util
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||||
|
@ -83,11 +84,12 @@ class BaseTest:
|
||||||
login.encode(encoding)).decode()
|
login.encode(encoding)).decode()
|
||||||
environ["REQUEST_METHOD"] = method.upper()
|
environ["REQUEST_METHOD"] = method.upper()
|
||||||
environ["PATH_INFO"] = path
|
environ["PATH_INFO"] = path
|
||||||
if data:
|
if data is not None:
|
||||||
data_bytes = data.encode(encoding)
|
data_bytes = data.encode(encoding)
|
||||||
environ["wsgi.input"] = BytesIO(data_bytes)
|
environ["wsgi.input"] = BytesIO(data_bytes)
|
||||||
environ["CONTENT_LENGTH"] = str(len(data_bytes))
|
environ["CONTENT_LENGTH"] = str(len(data_bytes))
|
||||||
environ["wsgi.errors"] = sys.stderr
|
environ["wsgi.errors"] = sys.stderr
|
||||||
|
wsgiref.util.setup_testing_defaults(environ)
|
||||||
status = headers = None
|
status = headers = None
|
||||||
|
|
||||||
def start_response(status_: str, headers_: List[Tuple[str, str]]
|
def start_response(status_: str, headers_: List[Tuple[str, str]]
|
||||||
|
@ -137,8 +139,8 @@ class BaseTest:
|
||||||
status, _, answer = self.request("GET", path, check=check, **kwargs)
|
status, _, answer = self.request("GET", path, check=check, **kwargs)
|
||||||
return status, answer
|
return status, answer
|
||||||
|
|
||||||
def post(self, path: str, data: str = None, check: Optional[int] = 200,
|
def post(self, path: str, data: Optional[str] = None,
|
||||||
**kwargs) -> Tuple[int, str]:
|
check: Optional[int] = 200, **kwargs) -> Tuple[int, str]:
|
||||||
status, _, answer = self.request("POST", path, data, check=check,
|
status, _, answer = self.request("POST", path, data, check=check,
|
||||||
**kwargs)
|
**kwargs)
|
||||||
return status, answer
|
return status, answer
|
||||||
|
|
|
@ -25,6 +25,7 @@ LAST-MODIFIED:20130902T150158Z
|
||||||
DTSTAMP:20130902T150158Z
|
DTSTAMP:20130902T150158Z
|
||||||
UID:event1
|
UID:event1
|
||||||
SUMMARY:Event
|
SUMMARY:Event
|
||||||
|
CATEGORIES:some_category1,another_category2
|
||||||
ORGANIZER:mailto:unclesam@example.com
|
ORGANIZER:mailto:unclesam@example.com
|
||||||
ATTENDEE;ROLE=REQ-PARTICIPANT;PARTSTAT=TENTATIVE;CN=Jane Doe:MAILTO:janedoe@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
|
ATTENDEE;ROLE=REQ-PARTICIPANT;DELEGATED-FROM="MAILTO:bob@host.com";PARTSTAT=ACCEPTED;CN=John Doe:MAILTO:johndoe@example.com
|
||||||
|
|
|
@ -355,7 +355,7 @@ permissions: RrWw""")
|
||||||
path2 = "/calendar.ics/event2.ics"
|
path2 = "/calendar.ics/event2.ics"
|
||||||
self.put(path1, event)
|
self.put(path1, event)
|
||||||
self.request("MOVE", path1, check=201,
|
self.request("MOVE", path1, check=201,
|
||||||
HTTP_DESTINATION=path2, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
self.get(path1, check=404)
|
self.get(path1, check=404)
|
||||||
self.get(path2)
|
self.get(path2)
|
||||||
|
|
||||||
|
@ -368,7 +368,7 @@ permissions: RrWw""")
|
||||||
path2 = "/calendar2.ics/event2.ics"
|
path2 = "/calendar2.ics/event2.ics"
|
||||||
self.put(path1, event)
|
self.put(path1, event)
|
||||||
self.request("MOVE", path1, check=201,
|
self.request("MOVE", path1, check=201,
|
||||||
HTTP_DESTINATION=path2, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
self.get(path1, check=404)
|
self.get(path1, check=404)
|
||||||
self.get(path2)
|
self.get(path2)
|
||||||
|
|
||||||
|
@ -382,7 +382,7 @@ permissions: RrWw""")
|
||||||
self.put(path1, event)
|
self.put(path1, event)
|
||||||
self.put("/calendar2.ics/event1.ics", event)
|
self.put("/calendar2.ics/event1.ics", event)
|
||||||
status, _, answer = self.request(
|
status, _, answer = self.request(
|
||||||
"MOVE", path1, HTTP_DESTINATION=path2, HTTP_HOST="")
|
"MOVE", path1, HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
assert status in (403, 409)
|
assert status in (403, 409)
|
||||||
xml = DefusedET.fromstring(answer)
|
xml = DefusedET.fromstring(answer)
|
||||||
assert xml.tag == xmlutils.make_clark("D:error")
|
assert xml.tag == xmlutils.make_clark("D:error")
|
||||||
|
@ -398,9 +398,9 @@ permissions: RrWw""")
|
||||||
self.put(path1, event)
|
self.put(path1, event)
|
||||||
self.put(path2, event)
|
self.put(path2, event)
|
||||||
self.request("MOVE", path1, check=412,
|
self.request("MOVE", path1, check=412,
|
||||||
HTTP_DESTINATION=path2, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
self.request("MOVE", path1, check=204,
|
self.request("MOVE", path1, check=204, HTTP_OVERWRITE="T",
|
||||||
HTTP_DESTINATION=path2, HTTP_HOST="", HTTP_OVERWRITE="T")
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
|
|
||||||
def test_move_between_colections_overwrite_uid_conflict(self) -> None:
|
def test_move_between_colections_overwrite_uid_conflict(self) -> None:
|
||||||
"""Move a item to a collection which already contains the item with
|
"""Move a item to a collection which already contains the item with
|
||||||
|
@ -413,8 +413,9 @@ permissions: RrWw""")
|
||||||
path2 = "/calendar2.ics/event2.ics"
|
path2 = "/calendar2.ics/event2.ics"
|
||||||
self.put(path1, event1)
|
self.put(path1, event1)
|
||||||
self.put(path2, event2)
|
self.put(path2, event2)
|
||||||
status, _, answer = self.request("MOVE", path1, HTTP_DESTINATION=path2,
|
status, _, answer = self.request(
|
||||||
HTTP_HOST="", HTTP_OVERWRITE="T")
|
"MOVE", path1, HTTP_OVERWRITE="T",
|
||||||
|
HTTP_DESTINATION="http://127.0.0.1/"+path2)
|
||||||
assert status in (403, 409)
|
assert status in (403, 409)
|
||||||
xml = DefusedET.fromstring(answer)
|
xml = DefusedET.fromstring(answer)
|
||||||
assert xml.tag == xmlutils.make_clark("D:error")
|
assert xml.tag == xmlutils.make_clark("D:error")
|
||||||
|
@ -916,6 +917,22 @@ permissions: RrWw""")
|
||||||
<C:text-match>event</C:text-match>
|
<C:text-match>event</C:text-match>
|
||||||
</C:prop-filter>
|
</C:prop-filter>
|
||||||
</C:comp-filter>
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>"""])
|
||||||
|
assert "/calendar.ics/event1.ics" in self._test_filter(["""\
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:prop-filter name="CATEGORIES">
|
||||||
|
<C:text-match>some_category1</C:text-match>
|
||||||
|
</C:prop-filter>
|
||||||
|
</C:comp-filter>
|
||||||
|
</C:comp-filter>"""])
|
||||||
|
assert "/calendar.ics/event1.ics" in self._test_filter(["""\
|
||||||
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
<C:comp-filter name="VEVENT">
|
||||||
|
<C:prop-filter name="CATEGORIES">
|
||||||
|
<C:text-match collation="i;octet">some_category1</C:text-match>
|
||||||
|
</C:prop-filter>
|
||||||
|
</C:comp-filter>
|
||||||
</C:comp-filter>"""])
|
</C:comp-filter>"""])
|
||||||
assert "/calendar.ics/event1.ics" not in self._test_filter(["""\
|
assert "/calendar.ics/event1.ics" not in self._test_filter(["""\
|
||||||
<C:comp-filter name="VCALENDAR">
|
<C:comp-filter name="VCALENDAR">
|
||||||
|
@ -1471,7 +1488,7 @@ permissions: RrWw""")
|
||||||
sync_token, responses = self._report_sync_token(calendar_path)
|
sync_token, responses = self._report_sync_token(calendar_path)
|
||||||
assert len(responses) == 1 and responses[event1_path] == 200
|
assert len(responses) == 1 and responses[event1_path] == 200
|
||||||
self.request("MOVE", event1_path, check=201,
|
self.request("MOVE", event1_path, check=201,
|
||||||
HTTP_DESTINATION=event2_path, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
|
||||||
sync_token, responses = self._report_sync_token(
|
sync_token, responses = self._report_sync_token(
|
||||||
calendar_path, sync_token)
|
calendar_path, sync_token)
|
||||||
if not self.full_sync_token_support and not sync_token:
|
if not self.full_sync_token_support and not sync_token:
|
||||||
|
@ -1490,9 +1507,9 @@ permissions: RrWw""")
|
||||||
sync_token, responses = self._report_sync_token(calendar_path)
|
sync_token, responses = self._report_sync_token(calendar_path)
|
||||||
assert len(responses) == 1 and responses[event1_path] == 200
|
assert len(responses) == 1 and responses[event1_path] == 200
|
||||||
self.request("MOVE", event1_path, check=201,
|
self.request("MOVE", event1_path, check=201,
|
||||||
HTTP_DESTINATION=event2_path, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+event2_path)
|
||||||
self.request("MOVE", event2_path, check=201,
|
self.request("MOVE", event2_path, check=201,
|
||||||
HTTP_DESTINATION=event1_path, HTTP_HOST="")
|
HTTP_DESTINATION="http://127.0.0.1/"+event1_path)
|
||||||
sync_token, responses = self._report_sync_token(
|
sync_token, responses = self._report_sync_token(
|
||||||
calendar_path, sync_token)
|
calendar_path, sync_token)
|
||||||
if not self.full_sync_token_support and not sync_token:
|
if not self.full_sync_token_support and not sync_token:
|
||||||
|
|
|
@ -60,8 +60,9 @@ class TestBaseServerRequests(BaseTest):
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||||
# Find available port
|
# Find available port
|
||||||
sock.bind(("127.0.0.1", 0))
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
self.sockfamily = socket.AF_INET
|
||||||
self.sockname = sock.getsockname()
|
self.sockname = sock.getsockname()
|
||||||
self.configure({"server": {"hosts": "[%s]:%d" % self.sockname},
|
self.configure({"server": {"hosts": "%s:%d" % self.sockname},
|
||||||
# Enable debugging for new processes
|
# Enable debugging for new processes
|
||||||
"logging": {"level": "debug"}})
|
"logging": {"level": "debug"}})
|
||||||
self.thread = threading.Thread(target=server.serve, args=(
|
self.thread = threading.Thread(target=server.serve, args=(
|
||||||
|
@ -105,8 +106,12 @@ class TestBaseServerRequests(BaseTest):
|
||||||
data_bytes = None
|
data_bytes = None
|
||||||
if data:
|
if data:
|
||||||
data_bytes = data.encode(encoding)
|
data_bytes = data.encode(encoding)
|
||||||
|
if self.sockfamily == socket.AF_INET6:
|
||||||
|
req_host = ("[%s]" % self.sockname[0])
|
||||||
|
else:
|
||||||
|
req_host = self.sockname[0]
|
||||||
req = request.Request(
|
req = request.Request(
|
||||||
"%s://[%s]:%d%s" % (scheme, *self.sockname, path),
|
"%s://%s:%d%s" % (scheme, req_host, self.sockname[1], path),
|
||||||
data=data_bytes, headers=headers, method=method)
|
data=data_bytes, headers=headers, method=method)
|
||||||
while True:
|
while True:
|
||||||
assert is_alive_fn()
|
assert is_alive_fn()
|
||||||
|
@ -161,6 +166,7 @@ class TestBaseServerRequests(BaseTest):
|
||||||
server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
server.COMPAT_IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
|
||||||
# Find available port
|
# Find available port
|
||||||
sock.bind(("::1", 0))
|
sock.bind(("::1", 0))
|
||||||
|
self.sockfamily = socket.AF_INET6
|
||||||
self.sockname = sock.getsockname()[:2]
|
self.sockname = sock.getsockname()[:2]
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT,
|
if e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT,
|
||||||
|
|
|
@ -50,8 +50,8 @@ if sys.version_info >= (3, 8):
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
class ErrorStream(Protocol):
|
class ErrorStream(Protocol):
|
||||||
def flush(self) -> None: ...
|
def flush(self) -> object: ...
|
||||||
def write(self, s: str) -> None: ...
|
def write(self, s: str) -> object: ...
|
||||||
else:
|
else:
|
||||||
ErrorStream = Any
|
ErrorStream = Any
|
||||||
InputStream = Any
|
InputStream = Any
|
||||||
|
|
|
@ -1,23 +1,27 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<script src="fn.js"></script>
|
<script src="fn.js"></script>
|
||||||
<title>Radicale Web Interface</title>
|
<title>Radicale Web Interface</title>
|
||||||
<link href="css/main.css" media="screen" rel="stylesheet">
|
<link href="css/main.css" media="screen" rel="stylesheet">
|
||||||
<link href="css/icon.png" type="image/png" rel="shortcut icon">
|
<link href="css/icon.png" type="image/png" rel="icon">
|
||||||
<style>
|
<style>
|
||||||
.hidden {display:none;}
|
.hidden {display:none;}
|
||||||
</style>
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li id="logoutview" class="hidden"><a href="" data-name="link">Logout [<span data-name="user" style="word-wrap:break-word;"></span>]</a></li>
|
<li id="logoutview" class="hidden"><a href="" data-name="link">Logout [<span data-name="user" style="word-wrap:break-word;"></span>]</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
<section id="loadingscene">
|
<section id="loadingscene">
|
||||||
<h1>Loading</h1>
|
<h1>Loading</h1>
|
||||||
<p>Please wait...</p>
|
<p>Please wait...</p>
|
||||||
|
@ -128,3 +132,7 @@
|
||||||
<button type="button" data-name="cancel">No</button>
|
<button type="button" data-name="cancel">No</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
|
|
@ -28,8 +28,9 @@ known_third_party = defusedxml,passlib,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)
|
||||||
select = E,F,W,C90,DOES-NOT-EXIST
|
# DNE: DOES-NOT-EXIST
|
||||||
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST
|
select = E,F,W,C90,DNE000
|
||||||
|
ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501
|
||||||
extend-exclude = build
|
extend-exclude = build
|
||||||
|
|
||||||
[mypy]
|
[mypy]
|
||||||
|
|
8
setup.py
Executable file → Normal file
8
setup.py
Executable file → Normal file
|
@ -19,7 +19,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 = "master"
|
VERSION = "3.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()
|
||||||
|
@ -33,7 +33,7 @@ install_requires = ["defusedxml", "passlib", "vobject>=0.9.6",
|
||||||
"setuptools; python_version<'3.9'"]
|
"setuptools; python_version<'3.9'"]
|
||||||
bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
|
bcrypt_requires = ["passlib[bcrypt]", "bcrypt"]
|
||||||
# typeguard requires pytest<7
|
# typeguard requires pytest<7
|
||||||
test_requires = ["pytest<7", "typeguard", "waitress", *bcrypt_requires]
|
test_requires = ["pytest<7", "typeguard<3", "waitress", *bcrypt_requires]
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name="Radicale",
|
name="Radicale",
|
||||||
|
@ -53,7 +53,7 @@ setup(
|
||||||
install_requires=install_requires,
|
install_requires=install_requires,
|
||||||
extras_require={"test": test_requires, "bcrypt": bcrypt_requires},
|
extras_require={"test": test_requires, "bcrypt": bcrypt_requires},
|
||||||
keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
|
keywords=["calendar", "addressbook", "CalDAV", "CardDAV"],
|
||||||
python_requires=">=3.6.0",
|
python_requires=">=3.7.0",
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Environment :: Console",
|
"Environment :: Console",
|
||||||
|
@ -63,11 +63,11 @@ setup(
|
||||||
"License :: OSI Approved :: GNU General Public License (GPL)",
|
"License :: OSI Approved :: GNU General Public License (GPL)",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.6",
|
|
||||||
"Programming Language :: Python :: 3.7",
|
"Programming Language :: Python :: 3.7",
|
||||||
"Programming Language :: Python :: 3.8",
|
"Programming Language :: Python :: 3.8",
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: Implementation :: CPython",
|
"Programming Language :: Python :: Implementation :: CPython",
|
||||||
"Programming Language :: Python :: Implementation :: PyPy",
|
"Programming Language :: Python :: Implementation :: PyPy",
|
||||||
"Topic :: Office/Business :: Groupware"])
|
"Topic :: Office/Business :: Groupware"])
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue