Compare commits

..

1 Commits

Author SHA1 Message Date
Lennart
41039242ee Some work on caldav imports 2025-06-11 00:17:57 +02:00
249 changed files with 4649 additions and 11235 deletions

View File

@@ -2,5 +2,3 @@
indent_style = space
indent_size = 4
[docs/**/*.md]
indent_size = 4

3
.gitattributes vendored
View File

@@ -1,3 +0,0 @@
# Otherwise GitHub thinks this is an HTML project
crates/frontend/public/assets/licenses.html linguist-detectable=false
crates/frontend/public/assets/js/* linguist-detectable=false

20
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,20 @@
name: Rust CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose --workspace

View File

@@ -1,57 +0,0 @@
name: "CICD"
on: [push, pull_request]
permissions:
contents: read
pull-requests: write
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- run: rustup update
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo check
test:
name: Test Suite
runs-on: ubuntu-latest
steps:
- run: rustup update
- name: Checkout sources
uses: actions/checkout@v4
- run: cargo test --all-features --verbose --workspace
coverage:
name: Test Coverage
runs-on: ubuntu-latest
steps:
- run: rustup update
- name: Install tarpaulin
run: cargo install cargo-tarpaulin
- name: Checkout sources
uses: actions/checkout@v4
- name: Run tarpaulin
run: cargo tarpaulin --workspace --all-features --exclude xml_derive --coveralls ${{ secrets.COVERALLS_REPO_TOKEN }}
lints:
name: Lints
runs-on: ubuntu-latest
steps:
- run: rustup update
- run: rustup component add rustfmt clippy
- name: Checkout sources
uses: actions/checkout@v4
- name: Run cargo fmt
run: cargo fmt --all -- --check
- name: Run cargo clippy
run: cargo clippy -- -D warnings

View File

@@ -3,9 +3,6 @@ name: Docker
on:
push:
branches: ["main"]
release:
types: ["published"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -41,10 +38,13 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# As long as we don't have releases everything on the main branch shall be tagged as latest
# TODO: Before first release correctly configure this
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}

4
.gitignore vendored
View File

@@ -12,7 +12,3 @@ principals.toml
.env
site
# Frontend
**/node_modules
**/.vite

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n REPLACE INTO principals\n (id, displayname, principal_type, password_hash)\n VALUES (?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "2f043f62a7c0eae1023e319f0bc8f35dfdcf6a8247e03b1de3e2cabb2d3ab8ae"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
"query": "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) or ?)",
"describe": {
"columns": [
{
@@ -22,5 +22,5 @@
false
]
},
"hash": "246ec675667992c1297c29348d46496a884c59adb8b64b569d36f4ce10f88f47"
"hash": "395e40a7b3333b79bc2ad50a123d99f74bc2712a16257ee2119dd211fdb61f7e"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT principal FROM memberships WHERE member_of = ?",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "3b00b59f047e534a7f7f654984dc880f4aa9281aae5974722d2f22ec6d15cb32"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "46ae176a06e314492f661c28436d6370883052c854da43475d7ced60cf8326e3"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO principals\n (id, displayname, principal_type, password_hash) VALUES (?, ?, ?, ?)\n ON CONFLICT(id) DO UPDATE SET\n (displayname, principal_type, password_hash)\n = (excluded.displayname, excluded.principal_type, excluded.password_hash)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "5c09c2a3c052188435409d4ff076575394e625dd19f00dea2d4c71a9f34a5952"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendars (principal, id, displayname, description, \"order\", color, subscription_url, timezone_id, push_topic, comp_event, comp_todo, comp_journal)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "60b940ff493e7c0fcb2ffe8ae97172c6444525ffeec21b194bd7443d11d06113"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT length(vcf) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM addressobjects WHERE principal = ? AND addressbook_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "660833e0505d3bbcd6dd736cce06b1bf14263d0e0e87b27d89d376d422e4e474"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?) ",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [
{
@@ -39,43 +39,48 @@
"type_info": "Text"
},
{
"name": "timezone_id",
"name": "timezone",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "deleted_at",
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"type_info": "Datetime"
},
{
"name": "subscription_url",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"name": "push_topic",
"ordinal": 11,
"type_info": "Bool"
"type_info": "Text"
},
{
"name": "comp_todo",
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
"Right": 3
"Right": 2
},
"nullable": [
false,
@@ -88,11 +93,12 @@
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "bb2fa030f2e7c7afdb38c5c54cb31de5293be332d86cf643977d479999542553"
"hash": "9f930775043a6d4571a8ffd5a981cadf7c51f3f11a189f8461505abec31076e6"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendars (principal, id, displayname, description, \"order\", color, timezone, timezone_id, push_topic, comp_event, comp_todo, comp_journal)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 12
},
"nullable": []
},
"hash": "c4134652b1efb1dda36fb59827bf9cfee6be5bddfd352f1da4e37c6b6aa0fa7a"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, displayname, \"order\", description, color, timezone_id, deleted_at, synctoken, subscription_url, push_topic, comp_event, comp_todo, comp_journal\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"query": "SELECT *\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"describe": {
"columns": [
{
@@ -14,14 +14,14 @@
"type_info": "Text"
},
{
"name": "displayname",
"name": "synctoken",
"ordinal": 2,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "order",
"name": "displayname",
"ordinal": 3,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "description",
@@ -29,49 +29,54 @@
"type_info": "Text"
},
{
"name": "color",
"name": "order",
"ordinal": 5,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "timezone_id",
"name": "color",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "deleted_at",
"name": "timezone",
"ordinal": 7,
"type_info": "Datetime"
},
{
"name": "synctoken",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "subscription_url",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"type_info": "Datetime"
},
{
"name": "subscription_url",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"name": "push_topic",
"ordinal": 11,
"type_info": "Bool"
"type_info": "Text"
},
{
"name": "comp_todo",
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
@@ -80,13 +85,14 @@
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
false,
true,
false,
false,
@@ -94,5 +100,5 @@
false
]
},
"hash": "27ac68a4eea40c1cac663cad034028cf6c373354b29e3a5290c18f58101913cd"
"hash": "cce62f7829bd688cd8c7928b587bc31f0e50865c214b1df113350bea2c254237"
}

View File

@@ -39,39 +39,44 @@
"type_info": "Text"
},
{
"name": "timezone_id",
"name": "timezone",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "deleted_at",
"name": "timezone_id",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 9,
"type_info": "Datetime"
},
{
"name": "subscription_url",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "push_topic",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "comp_event",
"name": "push_topic",
"ordinal": 11,
"type_info": "Bool"
"type_info": "Text"
},
{
"name": "comp_todo",
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_journal",
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
@@ -88,6 +93,7 @@
true,
true,
true,
true,
false,
false,
false,

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) OR ?)",
"query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?)",
"describe": {
"columns": [
{
@@ -15,12 +15,12 @@
}
],
"parameters": {
"Right": 4
"Right": 3
},
"nullable": [
false,
false
]
},
"hash": "543838c030550cb09d1af08adfeade8b7ce3575d92fddbc6e9582d141bc9e49d"
"hash": "d2f7423e2e8f97607f6664200990dcadb927445880ec6edffba3b5aedf4e199b"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET principal = ?, id = ?, displayname = ?, description = ?, \"order\" = ?, color = ?, timezone = ?, timezone_id = ?, push_topic = ?, comp_event = ?, comp_todo = ?, comp_journal = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 14
},
"nullable": []
},
"hash": "d65c9c40606e59dd816a51b9b9ac60fd2ff81aaa358fcc038134e9a68ba45ad7"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT length(ics) AS 'length!: u64', deleted_at AS 'deleted!: bool' FROM calendarobjects WHERE principal = ? AND cal_id = ?",
"describe": {
"columns": [
{
"name": "length!: u64",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "deleted!: bool",
"ordinal": 1,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 2
},
"nullable": [
null,
true
]
},
"hash": "d9f14260a46a7ccd137d462c35d350a7fe338a074131776596c5d803fcda1f48"
}

1580
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,10 @@
members = ["crates/*"]
[workspace.package]
version = "0.9.12"
version = "0.1.0"
edition = "2024"
description = "A CalDAV server"
documentation = "https://lennart-k.github.io/rustical/"
repository = "https://github.com/lennart-k/rustical"
license = "AGPL-3.0-or-later"
[package]
name = "rustical"
@@ -15,13 +13,11 @@ version.workspace = true
edition.workspace = true
description.workspace = true
repository.workspace = true
license.workspace = true
resolver = "2"
publish = true
publish = false
[features]
debug = ["opentelemetry"]
frontend-dev = ["rustical_frontend/dev"]
opentelemetry = [
"dep:opentelemetry",
"dep:opentelemetry-otlp",
@@ -30,12 +26,10 @@ opentelemetry = [
"dep:tracing-opentelemetry",
]
[profile.dev]
debug = 0
[workspace.dependencies]
matchit = "0.8"
uuid = { version = "1.11", features = ["v4", "fast-rng"] }
async-trait = "0.1"
axum = "0.8"
@@ -49,7 +43,7 @@ rand_core = { version = "0.9", features = ["std"] }
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
lazy_static = "1.5"
rstest = "0.26"
rstest = "0.25"
rstest_reuse = "0.7"
sha2 = "0.10"
tokio = { version = "1", features = [
@@ -62,7 +56,7 @@ tokio = { version = "1", features = [
url = "2.5"
base64 = "0.22"
thiserror = "2.0"
quick-xml = { version = "0.38" }
quick-xml = { version = "0.37" }
rust-embed = "8.5"
tower-sessions = "0.14"
futures-core = "0.3.31"
@@ -96,12 +90,8 @@ strum = "0.27"
strum_macros = "0.27"
serde_json = { version = "1.0", features = ["raw_value"] }
sqlx-sqlite = { version = "0.8", features = ["bundled"] }
ical = { git = "https://github.com/lennart-k/ical-rs", features = [
"generator",
"serde",
"chrono-tz",
] }
toml = "0.9"
ical = { version = "0.11", features = ["generator", "serde"] }
toml = "0.8"
tower = "0.5"
tower-http = { version = "0.6", features = [
"trace",
@@ -131,7 +121,7 @@ syn = { version = "2.0", features = ["full"] }
quote = "1.0"
proc-macro2 = "1.0"
heck = "0.5"
darling = "0.21"
darling = "0.20"
reqwest = { version = "0.12", features = [
"rustls-tls",
"charset",
@@ -139,13 +129,6 @@ reqwest = { version = "0.12", features = [
], default-features = false }
openidconnect = "4.0"
clap = { version = "4.5", features = ["derive", "env"] }
matchit-serde = { git = "https://github.com/lennart-k/matchit-serde", rev = "f0591d13" }
vtimezones-rs = "0.2"
ece = { version = "2.3", default-features = false, features = [
"backend-openssl",
] }
openssl = { version = "0.10", features = ["vendored"] }
async-std = { version = "1.13", features = ["attributes"] }
[dependencies]
rustical_store = { workspace = true }
@@ -164,15 +147,15 @@ async-trait = { workspace = true }
uuid.workspace = true
axum.workspace = true
opentelemetry = { version = "0.31", optional = true }
opentelemetry-otlp = { version = "0.31", optional = true, features = [
opentelemetry = { version = "0.30", optional = true }
opentelemetry-otlp = { version = "0.30", optional = true, features = [
"grpc-tonic",
] }
opentelemetry_sdk = { version = "0.31", features = [
opentelemetry_sdk = { version = "0.30", features = [
"rt-tokio",
], optional = true }
opentelemetry-semantic-conventions = { version = "0.31", optional = true }
tracing-opentelemetry = { version = "0.32", optional = true }
opentelemetry-semantic-conventions = { version = "0.30", optional = true }
tracing-opentelemetry = { version = "0.31", optional = true }
tracing-subscriber = { version = "0.3", features = [
"env-filter",
"fmt",

View File

@@ -1,11 +1,11 @@
FROM --platform=$BUILDPLATFORM rust:1.90-alpine AS chef
FROM --platform=$BUILDPLATFORM rust:1.86-alpine AS chef
ARG TARGETPLATFORM
ARG BUILDPLATFORM
# the compiler will otherwise ask for aarch64-linux-musl-gcc
ENV CC_aarch64_unknown_linux_musl="clang"
ENV AR_aarch64_unknown_linux_musl="llvm20-ar"
ENV AR_aarch64_unknown_linux_musl="llvm-ar"
ENV CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-Clink-self-contained=yes -Clinker=rust-lld"
# Stupid workaound with tempfiles since environment variables
@@ -16,7 +16,7 @@ RUN case $TARGETPLATFORM in \
*) echo "Unsupported platform ${TARGETPLATFORM}"; exit 1;; \
esac
RUN apk add --no-cache musl-dev llvm20 clang perl pkgconf make \
RUN apk add --no-cache musl-dev llvm19 clang \
&& rustup target add "$(cat /tmp/rust_target)" \
&& cargo install cargo-chef --locked \
&& rm -rf "$CARGO_HOME/registry"
@@ -45,5 +45,4 @@ CMD ["/usr/local/bin/rustical"]
ENV RUSTICAL_DATA_STORE__SQLITE__DB_URL=/var/lib/rustical/db.sqlite3
LABEL org.opencontainers.image.authors="Lennart K github.com/lennart-k"
LABEL org.opencontainers.image.licenses="AGPL-3.0-or-later"
EXPOSE 4000

View File

@@ -1,17 +1,2 @@
licenses:
cargo about generate about.hbs > crates/frontend/public/assets/licenses.html
frontend-dev:
cd crates/frontend/js-components && deno task dev
frontend-build:
cd crates/frontend/js-components && deno task build
docs:
mkdocs build
docs-dev:
mkdocs serve
coverage:
cargo tarpaulin --workspace --exclude xml_derive

View File

@@ -3,34 +3,22 @@
a CalDAV/CardDAV server
> [!WARNING]
RustiCal is under **active development**!
While I've been successfully using RustiCal productively for some months now and there seems to be a growing user base,
you'd still be one of the first testers so expect bugs and rough edges.
If you still want to use it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
> RustiCal is **not production-ready!**
> I'm just starting to use it myself so I cannot guarantee that everything will be working smoothly just yet.
> I hope there won't be any manual migrations anymore but if you want to be an early adopter some SQL knowledge might be useful just in case.
> If you still want to play around with it in its current state, absolutely feel free to do so and to open up an issue if something is not working. :)
## Features
- easy to backup, everything saved in one SQLite database
- also export feature in the frontend
- Import your existing calendars in the frontend
- **[WebDAV Push](https://github.com/bitfireAT/webdav-push/)** support, so near-instant synchronisation to DAVx5
- ~~[WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5~~ (currently broken)
- lightweight (the container image contains only one binary)
- adequately fast (I'd love to say blazingly fast™ :fire: but I don't have any benchmarks)
- deleted calendars are recoverable
- Nextcloud login flow (In DAVx5 you can login through the Nextcloud flow and automatically generate an app token)
- Apple configuration profiles (skip copy-pasting passwords and instead generate the configuration in the frontend)
- **OpenID Connect** support (with option to disable password login)
- Group-based **sharing**
- OpenID Connect support (with option to disable password login)
## Getting Started
- Check out the [documentation](https://lennart-k.github.io/rustical/installation/)
## Tested Clients
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar
- Home Assistant integration
- Thunderbird

View File

@@ -7,7 +7,5 @@ accepted = [
"CDLA-Permissive-2.0",
"Zlib",
"AGPL-3.0",
"GPL-3.0",
"MPL-2.0",
]
workarounds = ["ring", "chrono", "rustls"]

View File

@@ -1,22 +0,0 @@
services:
rustical:
image: ghcr.io/lennart-k/rustical:latest
restart: unless-stopped
environment:
RUSTICAL_FRONTEND__ALLOW_PASSWORD_LOGIN: "false"
RUSTICAL_OIDC__NAME: "Authelia"
RUSTICAL_OIDC__ISSUER: "https://auth.example.com"
RUSTICAL_OIDC__CLIENT_ID: "{{ rustical_oidc_client_id }}"
RUSTICAL_OIDC__CLIENT_SECRET: "{{ rustical_oidc_client_secret }}"
RUSTICAL_OIDC__CLAIM_USERID: "preferred_username"
RUSTICAL_OIDC__SCOPES: '["openid", "profile", "groups"]'
RUSTICAL_OIDC__REQUIRE_GROUP: "app:rustical" # optional
RUSTICAL_OIDC__ALLOW_SIGN_UP: "true"
volumes:
- data:/var/lib/rustical
# Here you probably want to you expose instead
ports:
- 4000:4000
volumes:
data:

View File

@@ -4,15 +4,8 @@ version.workspace = true
edition.workspace = true
description.workspace = true
repository.workspace = true
license.workspace = true
publish = false
[dev-dependencies]
rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true
async-std.workspace = true
serde_json.workspace = true
[dependencies]
axum.workspace = true
axum-extra.workspace = true
@@ -41,6 +34,3 @@ rustical_ical.workspace = true
http.workspace = true
headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true
vtimezones-rs.workspace = true

View File

@@ -4,12 +4,12 @@ use axum::body::Body;
use axum::extract::State;
use axum::{extract::Path, response::Response};
use headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, Method, StatusCode, header};
use http::{HeaderValue, StatusCode, header};
use ical::generator::{Emitter, IcalCalendarBuilder};
use ical::property::Property;
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use rustical_ical::{CalendarObjectComponent, EventObject, JournalObject, TodoObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::User};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::instrument;
@@ -18,90 +18,74 @@ use tracing::instrument;
pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
Path((principal, calendar_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: Principal,
method: Method,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store
.get_calendar(&principal, &calendar_id, true)
.await?;
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
let mut timezones = HashMap::new();
let mut vtimezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;
let mut ical_calendar_builder = IcalCalendarBuilder::version("4.0")
.gregorian()
.prodid("RustiCal");
if let Some(displayname) = calendar.meta.displayname {
if calendar.displayname.is_some() {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALNAME".to_owned(),
value: Some(displayname),
value: calendar.displayname,
params: None,
});
}
if let Some(description) = calendar.meta.description {
if calendar.description.is_some() {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-CALDESC".to_owned(),
value: Some(description),
value: calendar.description,
params: None,
});
}
if let Some(timezone_id) = calendar.timezone_id {
if calendar.timezone_id.is_some() {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-WR-TIMEZONE".to_owned(),
value: Some(timezone_id),
value: calendar.timezone_id,
params: None,
});
}
if calendar.color.is_some() {
ical_calendar_builder = ical_calendar_builder.set(Property {
name: "X-RUSTICAL-COLOR".to_owned(),
value: calendar.color,
params: None,
});
}
let mut ical_calendar = ical_calendar_builder.build();
for object in &objects {
vtimezones.extend(object.get_vtimezones());
match object.get_data() {
CalendarObjectComponent::Event(
EventObject {
event,
timezones: object_timezones,
..
},
overrides,
) => {
CalendarObjectComponent::Event(EventObject {
event,
timezones: object_timezones,
..
}) => {
timezones.extend(object_timezones);
ical_calendar_builder = ical_calendar_builder.add_event(event.clone());
for _override in overrides {
ical_calendar_builder =
ical_calendar_builder.add_event(_override.event.clone());
}
ical_calendar.events.push(event.clone());
}
CalendarObjectComponent::Todo(todo, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_todo(todo.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_todo(_override.clone());
}
CalendarObjectComponent::Todo(TodoObject { todo, .. }) => {
ical_calendar.todos.push(todo.clone());
}
CalendarObjectComponent::Journal(journal, overrides) => {
ical_calendar_builder = ical_calendar_builder.add_journal(journal.clone());
for _override in overrides {
ical_calendar_builder = ical_calendar_builder.add_journal(_override.clone());
}
CalendarObjectComponent::Journal(JournalObject { journal, .. }) => {
ical_calendar.journals.push(journal.clone());
}
}
}
for vtimezone in vtimezones.into_values() {
ical_calendar_builder = ical_calendar_builder.add_tz(vtimezone.to_owned());
}
let ical_calendar = ical_calendar_builder
.build()
.map_err(|parser_error| Error::IcalError(parser_error.into()))?;
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
@@ -115,9 +99,5 @@ pub async fn route_get<C: CalendarStore, S: SubscriptionStore>(
))
.unwrap(),
);
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}
Ok(resp.body(Body::new(ical_calendar.generate())).unwrap())
}

View File

@@ -1,110 +0,0 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
generator::Emitter,
parser::{Component, ComponentMut},
};
use rustical_dav::header::Overwrite;
use rustical_ical::{CalendarObject, CalendarObjectType};
use rustical_store::{
Calendar, CalendarMetadata, CalendarStore, SubscriptionStore, auth::Principal,
};
use std::io::BufReader;
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<CalendarResourceService<C, S>>,
Overwrite(overwrite): Overwrite,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut parser = ical::IcalParser::new(BufReader::new(body.as_bytes()));
let mut cal = parser
.next()
.expect("input must contain calendar")
.unwrap()
.mutable();
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
// Extract calendar metadata
let displayname = cal
.get_property("X-WR-CALNAME")
.and_then(|prop| prop.value.to_owned());
let description = cal
.get_property("X-WR-CALDESC")
.and_then(|prop| prop.value.to_owned());
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value.to_owned());
// These properties should not appear in the expanded calendar objects
cal.remove_property("X-WR-CALNAME");
cal.remove_property("X-WR-CALDESC");
cal.remove_property("X-WR-TIMEZONE");
let cal = cal.verify().unwrap();
// Make sure timezone is valid
if let Some(timezone_id) = timezone_id.as_ref() {
assert!(
vtimezones_rs::VTIMEZONES.contains_key(timezone_id),
"Invalid calendar timezone id"
);
}
// Extract necessary component types
let mut cal_components = vec![];
if !cal.events.is_empty() {
cal_components.push(CalendarObjectType::Event);
}
if !cal.journals.is_empty() {
cal_components.push(CalendarObjectType::Journal);
}
if !cal.todos.is_empty() {
cal_components.push(CalendarObjectType::Todo);
}
let expanded_cals = cal.expand_calendar();
// Janky way to convert between IcalCalendar and CalendarObject
let objects = expanded_cals
.into_iter()
.map(|cal| cal.generate())
.map(CalendarObject::from_ics)
.collect::<Result<Vec<_>, _>>()?;
let new_cal = Calendar {
principal,
id: cal_id,
meta: CalendarMetadata {
displayname,
order: 0,
description,
color: None,
},
timezone_id,
deleted_at: None,
synctoken: 0,
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
components: cal_components,
};
let cal_store = resource_service.cal_store;
cal_store
.import_calendar(new_cal, objects, overwrite)
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -4,11 +4,9 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use ical::IcalParser;
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::Principal;
use rustical_store::{Calendar, CalendarMetadata, CalendarStore, SubscriptionStore};
use rustical_store::auth::User;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument;
@@ -31,8 +29,6 @@ pub struct MkcolCalendarProp {
resourcetype: Option<Unparsed>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
supported_calendar_component_set: Option<SupportedCalendarComponentSet>,
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
source: Option<HrefElement>,
// Ignore that property, we don't support it but also don't want to throw an error
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
#[allow(dead_code)]
@@ -46,7 +42,7 @@ pub struct PropElement {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = "mkcalendar")]
#[xml(root = b"mkcalendar")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
struct MkcalendarRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -54,7 +50,7 @@ struct MkcalendarRequest {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = "mkcol")]
#[xml(root = b"mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -64,7 +60,7 @@ struct MkcolRequest {
#[instrument(skip(cal_store))]
pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
user: User,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
method: Method,
body: String,
@@ -73,55 +69,24 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let mut request = match method.as_str() {
let request = match method.as_str() {
"MKCALENDAR" => MkcalendarRequest::parse_str(&body)?.set.prop,
"MKCOL" => MkcolRequest::parse_str(&body)?.set.prop,
_ => unreachable!("We never call with another method"),
};
if let Some("") = request.displayname.as_deref() {
request.displayname = None
}
let timezone_id = if let Some(tzid) = request.calendar_timezone_id {
Some(tzid)
} else if let Some(tz) = request.calendar_timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
let timezone = calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone
.try_into()
.map_err(|_| rustical_dav::Error::BadRequest("No timezone data provided".to_owned()))?;
Some(timezone.name().to_owned())
} else {
None
};
let calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
meta: CalendarMetadata {
order: request.calendar_order.unwrap_or(0),
displayname: request.displayname,
color: request.calendar_color,
description: request.calendar_description,
},
timezone_id,
order: request.calendar_order.unwrap_or(0),
displayname: request.displayname,
timezone: request.calendar_timezone,
timezone_id: request.calendar_timezone_id,
color: request.calendar_color,
description: request.calendar_description,
deleted_at: None,
synctoken: 0,
subscription_url: request.source.map(|href| href.href),
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
components: request
.supported_calendar_component_set

View File

@@ -1,5 +1,5 @@
pub mod get;
pub mod import;
pub mod mkcalendar;
pub mod post;
// pub mod post;
pub mod get;
pub mod put;
pub mod report;

View File

@@ -1,13 +1,12 @@
use crate::Error;
use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use crate::calendar::resource::{CalendarResource, CalendarResourceService};
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use http::{HeaderMap, StatusCode, header};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_dav_push::register::PushRegister;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_store::{CalendarStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument;
use tracing::instrument;
@@ -15,7 +14,7 @@ use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
user: User,
State(resource_service): State<CalendarResourceService<C, S>>,
body: String,
) -> Result<Response, Error> {
@@ -25,7 +24,7 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
let calendar = resource_service
.cal_store
.get_calendar(&principal, &cal_id, false)
.get_calendar(&principal, &cal_id)
.await?;
let calendar_resource = CalendarResource {
cal: calendar,
@@ -74,17 +73,20 @@ pub async fn route_post<C: CalendarStore, S: SubscriptionStore>(
.upsert_subscription(subscription)
.await?;
// TODO: make nicer
let location = format!("/push_subscription/{sub_id}");
// let location = req
// .resource_map()
// .url_for(&req, "subscription", &[sub_id])
// .unwrap();
//
let location = "asd";
Ok((
StatusCode::CREATED,
HeaderMap::from_iter([
(header::LOCATION, HeaderValue::from_str(&location).unwrap()),
(
header::EXPIRES,
HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
HeaderMap::from_iter([(header::LOCATION, location)]),
)
.into_response())
.into_response());
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
}

View File

@@ -0,0 +1,101 @@
use std::collections::HashMap;
use crate::calendar::prop::SupportedCalendarComponent;
use crate::calendar::{self, CalendarResourceService};
use crate::{Error, calendar_set};
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::generator::Emitter;
use ical::parser::ical::component::IcalTimeZone;
use ical::{IcalParser, parser::Component};
use rustical_ical::CalendarObjectType;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore, auth::User};
use tracing::instrument;
#[instrument(skip(cal_store))]
pub async fn route_put<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
user: User,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
}
let mut parser = IcalParser::new(body.as_bytes());
let cal = parser
.next()
.ok_or(rustical_ical::Error::MissingCalendar)?
.map_err(rustical_ical::Error::from)?;
if parser.next().is_some() {
return Err(rustical_ical::Error::InvalidData(
"multiple calendars, only one allowed".to_owned(),
)
.into());
}
if !cal.alarms.is_empty() || !cal.free_busys.is_empty() {
return Err(rustical_ical::Error::InvalidData(
"Importer does not support VALARM and VFREEBUSY components".to_owned(),
)
.into());
}
let mut objects = vec![];
for event in cal.events {}
for todo in cal.todos {}
for journal in cal.journals {}
let timezones: HashMap<String, IcalTimeZone> = cal
.timezones
.clone()
.into_iter()
.filter_map(|timezone| {
let timezone_prop = timezone.get_property("TZID")?.to_owned();
let tzid = timezone_prop.value?;
Some((tzid, timezone))
})
.collect();
let displayname = cal.get_property("X-WR-CALNAME").and_then(|prop| prop.value);
let description = cal.get_property("X-WR-CALDESC").and_then(|prop| prop.value);
let color = cal
.get_property("X-RUSTICAL-COLOR")
.and_then(|prop| prop.value);
let timezone_id = cal
.get_property("X-WR-TIMEZONE")
.and_then(|prop| prop.value);
let timezone = timezone_id
.and_then(|tzid| timezones.get(&tzid))
.map(|timezone| timezone.generate());
let mut components = vec![CalendarObjectType::Event, CalendarObjectType::Todo];
if !cal.journals.is_empty() {
components.push(CalendarObjectType::Journal);
}
let calendar = Calendar {
principal: principal.clone(),
id: cal_id,
displayname,
description,
color,
timezone_id,
timezone,
components,
subscription_url: None,
push_topic: uuid::Uuid::new_v4().to_string(),
synctoken: 0,
deleted_at: None,
order: 0,
};
cal_store
.import_calendar(&principal, calendar, objects)
.await?;
Ok(StatusCode::CREATED.into_response())
}

View File

@@ -29,7 +29,7 @@ pub async fn get_objects_calendar_multiget<C: CalendarStore>(
if let Some(filename) = href.strip_prefix(path) {
let filename = filename.trim_start_matches("/");
if let Some(object_id) = filename.strip_suffix(".ics") {
match store.get_object(principal, cal_id, object_id, false).await {
match store.get_object(principal, cal_id, object_id).await {
Ok(object) => result.push(object),
Err(rustical_store::Error::NotFound) => not_found.push(href.to_owned()),
Err(err) => return Err(err.into()),

View File

@@ -1,7 +1,7 @@
use crate::calendar_object::CalendarObjectPropWrapperName;
use crate::{Error, calendar_object::CalendarObjectPropWrapperName};
use rustical_dav::xml::PropfindType;
use rustical_ical::{CalendarObject, UtcDateTime};
use rustical_store::calendar_store::CalendarQuery;
use rustical_store::{CalendarStore, calendar_store::CalendarQuery};
use rustical_xml::XmlDeserialize;
use std::ops::Deref;
@@ -16,42 +16,36 @@ pub(crate) struct TimeRangeElement {
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.3
pub struct ParamFilterElement {
struct ParamFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) text_match: Option<TextMatchElement>,
text_match: Option<TextMatchElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
name: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
pub struct TextMatchElement {
struct TextMatchElement {
#[xml(ty = "attr")]
pub(crate) collation: String,
collation: String,
#[xml(ty = "attr")]
// "yes" or "no", default: "no"
pub(crate) negate_condition: Option<String>,
negate_collation: String,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
#[allow(dead_code)]
// https://www.rfc-editor.org/rfc/rfc4791#section-9.7.2
pub(crate) struct PropFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) is_not_defined: Option<()>,
is_not_defined: Option<()>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) time_range: Option<TimeRangeElement>,
time_range: Option<TimeRangeElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
pub(crate) text_match: Option<TextMatchElement>,
text_match: Option<TextMatchElement>,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) param_filter: Vec<ParamFilterElement>,
#[xml(ty = "attr")]
pub(crate) name: String,
param_filter: Vec<ParamFilterElement>,
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -67,7 +61,7 @@ pub(crate) struct CompFilterElement {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", flatten)]
pub(crate) comp_filter: Vec<CompFilterElement>,
#[xml(ty = "attr")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", ty = "attr")]
pub(crate) name: String,
}
@@ -116,17 +110,19 @@ impl CompFilterElement {
// TODO: Implement prop-filter (and comp-filter?) at some point
if let Some(time_range) = &self.time_range {
if let Some(start) = &time_range.start
&& let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None)
&& start.deref() > &last_occurence.utc()
{
return false;
if let Some(start) = &time_range.start {
if let Some(last_occurence) = cal_object.get_last_occurence().unwrap_or(None) {
if start.deref() > &last_occurence.utc() {
return false;
}
};
}
if let Some(end) = &time_range.end
&& let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None)
&& end.deref() < &first_occurence.utc()
{
return false;
if let Some(end) = &time_range.end {
if let Some(first_occurence) = cal_object.get_first_occurence().unwrap_or(None) {
if end.deref() < &first_occurence.utc() {
return false;
}
};
}
}
true
@@ -154,15 +150,15 @@ impl From<&FilterElement> for CalendarQuery {
for comp_filter in comp_filter_vcalendar.comp_filter.iter() {
// A calendar object cannot contain both VEVENT and VTODO, so we only have to handle
// whatever we get first
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO")
&& let Some(time_range) = &comp_filter.time_range
{
let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive());
return CalendarQuery {
time_start: start,
time_end: end,
};
if matches!(comp_filter.name.as_str(), "VEVENT" | "VTODO") {
if let Some(time_range) = &comp_filter.time_range {
let start = time_range.start.as_ref().map(|start| start.date_naive());
let end = time_range.end.as_ref().map(|end| end.date_naive());
return CalendarQuery {
time_start: start,
time_end: end,
};
}
}
}
Default::default()
@@ -192,3 +188,18 @@ impl From<&CalendarQueryRequest> for CalendarQuery {
.unwrap_or_default()
}
}
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<CalendarObject>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|object| filter.matches(object));
}
Ok(objects)
}

View File

@@ -1,120 +0,0 @@
use crate::Error;
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
mod elements;
pub(crate) use elements::*;
pub async fn get_objects_calendar_query<C: CalendarStore>(
cal_query: &CalendarQueryRequest,
principal: &str,
cal_id: &str,
store: &C,
) -> Result<Vec<CalendarObject>, Error> {
let mut objects = store
.calendar_query(principal, cal_id, cal_query.into())
.await?;
if let Some(filter) = &cal_query.filter {
objects.retain(|object| filter.matches(object));
}
Ok(objects)
}
#[cfg(test)]
mod tests {
use rustical_dav::xml::PropElement;
use rustical_xml::XmlDocument;
use crate::{
calendar::methods::report::{
ReportRequest,
calendar_query::{
CalendarQueryRequest, CompFilterElement, FilterElement, ParamFilterElement,
PropFilterElement, TextMatchElement,
},
},
calendar_object::{CalendarObjectPropName, CalendarObjectPropWrapperName},
};
#[test]
fn calendar_query_7_8_7() {
const INPUT: &str = r#"
<?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/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT">
<C:prop-filter name="ATTENDEE">
<C:text-match collation="i;ascii-casemap">mailto:lisa@example.com</C:text-match>
<C:param-filter name="PARTSTAT">
<C:text-match collation="i;ascii-casemap">NEEDS-ACTION</C:text-match>
</C:param-filter>
</C:prop-filter>
</C:comp-filter>
</C:comp-filter>
</C:filter>
</C:calendar-query>
"#;
let report = ReportRequest::parse_str(INPUT).unwrap();
let calendar_query: CalendarQueryRequest =
if let ReportRequest::CalendarQuery(query) = report {
query
} else {
panic!()
};
assert_eq!(
calendar_query,
CalendarQueryRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(
vec![
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::Getetag,
),
CalendarObjectPropWrapperName::CalendarObject(
CalendarObjectPropName::CalendarData(Default::default())
),
],
vec![]
)),
filter: Some(FilterElement {
comp_filter: CompFilterElement {
is_not_defined: None,
time_range: None,
prop_filter: vec![],
comp_filter: vec![CompFilterElement {
prop_filter: vec![PropFilterElement {
name: "ATTENDEE".to_owned(),
text_match: Some(TextMatchElement {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
is_not_defined: None,
param_filter: vec![ParamFilterElement {
is_not_defined: None,
name: "PARTSTAT".to_owned(),
text_match: Some(TextMatchElement {
collation: "i;ascii-casemap".to_owned(),
negate_condition: None
}),
}],
time_range: None
}],
comp_filter: vec![],
is_not_defined: None,
name: "VEVENT".to_owned(),
time_range: None
}],
name: "VCALENDAR".to_owned()
}
}),
timezone: None,
timezone_id: None
}
)
}
}

View File

@@ -21,7 +21,7 @@ use rustical_dav::{
},
};
use rustical_ical::CalendarObject;
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use rustical_store::{CalendarStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection;
use tracing::instrument;
@@ -56,7 +56,7 @@ fn objects_response(
path: &str,
principal: &str,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &PropfindType<CalendarObjectPropWrapperName>,
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
@@ -67,7 +67,7 @@ fn objects_response(
object,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,
.propfind(&path, prop, puri, user)?,
);
}
@@ -90,7 +90,7 @@ fn objects_response(
#[instrument(skip(cal_store))]
pub async fn route_report_calendar<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: Principal,
user: User,
Extension(puri): Extension<CalDavPrincipalUri>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
OriginalUri(uri): OriginalUri,
@@ -149,7 +149,7 @@ mod tests {
use super::*;
use crate::calendar_object::{CalendarData, CalendarObjectPropName, ExpandElement};
use calendar_query::{CompFilterElement, FilterElement, TimeRangeElement};
use rustical_dav::{extensions::CommonPropertiesPropName, xml::PropElement};
use rustical_dav::xml::PropElement;
use rustical_ical::UtcDateTime;
use rustical_xml::{NamespaceOwned, ValueDeserialize};
@@ -160,6 +160,7 @@ mod tests {
<calendar-multiget xmlns="urn:ietf:params:xml:ns:caldav" xmlns:D="DAV:">
<D:prop>
<D:getetag/>
<D:displayname/>
<calendar-data>
<expand start="20250426T220000Z" end="20250503T220000Z"/>
</calendar-data>
@@ -179,7 +180,7 @@ mod tests {
end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
}), limit_recurrence_set: None, limit_freebusy_set: None }
)),
], vec![])),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]
@@ -252,7 +253,6 @@ mod tests {
<D:prop>
<D:getetag/>
<D:displayname/>
<D:invalid-prop/>
</D:prop>
<D:href>/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b</D:href>
</calendar-multiget>
@@ -263,8 +263,7 @@ mod tests {
ReportRequest::CalendarMultiget(CalendarMultigetRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
CalendarObjectPropWrapperName::Common(CommonPropertiesPropName::Displayname),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "invalid-prop".to_string())])),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
};
use rustical_store::{
CalendarStore,
auth::Principal,
auth::User,
synctoken::{format_synctoken, parse_synctoken},
};
@@ -21,7 +21,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
sync_collection: &SyncCollectionRequest<CalendarObjectPropWrapperName>,
path: &str,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
principal: &str,
cal_id: &str,
cal_store: &C,
@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<C: CalendarStore>(
object,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, None, puri, user)?,
.propfind(&path, &sync_collection.prop, puri, user)?,
);
}

View File

@@ -4,6 +4,3 @@ pub mod resource;
mod service;
pub use service::CalendarResourceService;
#[cfg(test)]
pub mod tests;

View File

@@ -1,7 +1,6 @@
use derive_more::derive::{From, Into};
use rustical_ical::CalendarObjectType;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, PartialEq, From, Into)]
pub struct SupportedCalendarComponent {
@@ -59,12 +58,39 @@ pub struct SupportedCalendarData {
calendar_data: CalendarData,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarQuery,
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct ReportWrapper {
report: ReportMethod,
}
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet {
#[xml(flatten)]
supported_report: Vec<ReportWrapper>,
}
impl Default for SupportedReportSet {
fn default() -> Self {
Self {
supported_report: vec![
ReportWrapper {
report: ReportMethod::CalendarQuery,
},
ReportWrapper {
report: ReportMethod::CalendarMultiget,
},
ReportWrapper {
report: ReportMethod::SyncCollection,
},
],
}
}
}

View File

@@ -1,26 +1,28 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet};
use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use ical::IcalParser;
use rustical_dav::extensions::{
CommonPropertiesExtension, CommonPropertiesProp, SyncTokenExtension, SyncTokenExtensionProp,
};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav_push::DavPushExtension;
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use serde::Deserialize;
use std::str::FromStr;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "CalendarPropName")]
pub enum CalendarProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarColor(Option<String>),
@@ -35,15 +37,15 @@ pub enum CalendarProp {
CalendarTimezoneId(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_ICAL")]
CalendarOrder(Option<i64>),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarComponentSet(SupportedCalendarComponentSet),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
SupportedCalendarData(SupportedCalendarData),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
#[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
Source(Option<HrefElement>),
#[xml(skip_deserializing)]
@@ -59,11 +61,11 @@ pub enum CalendarProp {
pub enum CalendarPropWrapper {
Calendar(CalendarProp),
SyncToken(SyncTokenExtensionProp),
DavPush(DavPushExtensionProp),
// DavPush(DavPushExtensionProp),
Common(CommonPropertiesProp),
}
#[derive(Clone, Debug, From, Into, Deserialize)]
#[derive(Clone, Debug, From, Into)]
pub struct CalendarResource {
pub cal: Calendar,
pub read_only: bool,
@@ -96,11 +98,9 @@ impl DavPushExtension for CalendarResource {
impl Resource for CalendarResource {
type Prop = CalendarPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
true
}
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
if self.cal.subscription_url.is_none() {
@@ -122,21 +122,22 @@ impl Resource for CalendarResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &CalendarPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
CalendarPropWrapperName::Calendar(prop) => CalendarPropWrapper::Calendar(match prop {
CalendarPropName::Displayname => {
CalendarProp::Displayname(self.cal.displayname.clone())
}
CalendarPropName::CalendarColor => {
CalendarProp::CalendarColor(self.cal.meta.color.clone())
CalendarProp::CalendarColor(self.cal.color.clone())
}
CalendarPropName::CalendarDescription => {
CalendarProp::CalendarDescription(self.cal.meta.description.clone())
CalendarProp::CalendarDescription(self.cal.description.clone())
}
CalendarPropName::CalendarTimezone => {
CalendarProp::CalendarTimezone(self.cal.timezone_id.as_ref().and_then(|tzid| {
vtimezones_rs::VTIMEZONES.get(tzid).map(|tz| tz.to_string())
}))
CalendarProp::CalendarTimezone(self.cal.timezone.clone())
}
// chrono_tz uses the IANA database
CalendarPropName::TimezoneServiceSet => CalendarProp::TimezoneServiceSet(
@@ -146,7 +147,7 @@ impl Resource for CalendarResource {
CalendarProp::CalendarTimezoneId(self.cal.timezone_id.clone())
}
CalendarPropName::CalendarOrder => {
CalendarProp::CalendarOrder(Some(self.cal.meta.order))
CalendarProp::CalendarOrder(Some(self.cal.order))
}
CalendarPropName::SupportedCalendarComponentSet => {
CalendarProp::SupportedCalendarComponentSet(self.cal.components.clone().into())
@@ -156,7 +157,7 @@ impl Resource for CalendarResource {
}
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000),
CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::all())
CalendarProp::SupportedReportSet(SupportedReportSet::default())
}
CalendarPropName::Source => CalendarProp::Source(
self.cal.subscription_url.to_owned().map(HrefElement::from),
@@ -171,9 +172,9 @@ impl Resource for CalendarResource {
CalendarPropWrapperName::SyncToken(prop) => {
CalendarPropWrapper::SyncToken(SyncTokenExtension::get_prop(self, prop)?)
}
CalendarPropWrapperName::DavPush(prop) => {
CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?)
}
// CalendarPropWrapperName::DavPush(prop) => {
// CalendarPropWrapper::DavPush(DavPushExtension::get_prop(self, prop)?)
// }
CalendarPropWrapperName::Common(prop) => CalendarPropWrapper::Common(
CommonPropertiesExtension::get_prop(self, puri, user, prop)?,
),
@@ -186,57 +187,40 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::Displayname(displayname) => {
self.cal.displayname = displayname;
Ok(())
}
CalendarProp::CalendarColor(color) => {
self.cal.meta.color = color;
self.cal.color = color;
Ok(())
}
CalendarProp::CalendarDescription(description) => {
self.cal.meta.description = description;
self.cal.description = description;
Ok(())
}
CalendarProp::CalendarTimezone(timezone) => {
if let Some(tz) = timezone {
// TODO: Proper error (calendar-timezone precondition)
let calendar = IcalParser::new(tz.as_bytes())
.next()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?
.map_err(|_| {
rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
)
})?;
let timezone =
calendar
.timezones
.first()
.ok_or(rustical_dav::Error::BadRequest(
"No timezone data provided".to_owned(),
))?;
let timezone: chrono_tz::Tz = timezone.try_into().map_err(|_| {
rustical_dav::Error::BadRequest("No timezone data provided".to_owned())
})?;
self.cal.timezone_id = Some(timezone.name().to_owned());
}
// TODO: Ensure that timezone-id is also updated
self.cal.timezone = timezone;
Ok(())
}
CalendarProp::TimezoneServiceSet(_) => Err(rustical_dav::Error::PropReadOnly),
CalendarProp::CalendarTimezoneId(timezone_id) => {
if let Some(tzid) = &timezone_id
&& !vtimezones_rs::VTIMEZONES.contains_key(tzid)
{
return Err(rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {tzid}"
)));
if let Some(tzid) = &timezone_id {
// Validate timezone id
chrono_tz::Tz::from_str(tzid).map_err(|_| {
rustical_dav::Error::BadRequest(format!(
"Invalid timezone-id: {}",
tzid
))
})?;
// TODO: Ensure that timezone is also updated (For now hope that clients play nice)
}
self.cal.timezone_id = timezone_id;
Ok(())
}
CalendarProp::CalendarOrder(order) => {
self.cal.meta.order = order.unwrap_or_default();
self.cal.order = order.unwrap_or_default();
Ok(())
}
CalendarProp::SupportedCalendarComponentSet(comp_set) => {
@@ -252,7 +236,7 @@ impl Resource for CalendarResource {
CalendarProp::MaxDateTime(_) => Err(rustical_dav::Error::PropReadOnly),
},
CalendarPropWrapper::SyncToken(prop) => SyncTokenExtension::set_prop(self, prop),
CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
// CalendarPropWrapper::DavPush(prop) => DavPushExtension::set_prop(self, prop),
CalendarPropWrapper::Common(prop) => CommonPropertiesExtension::set_prop(self, prop),
}
}
@@ -263,21 +247,29 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::Displayname => {
self.cal.displayname = None;
Ok(())
}
CalendarPropName::CalendarColor => {
self.cal.meta.color = None;
self.cal.color = None;
Ok(())
}
CalendarPropName::CalendarDescription => {
self.cal.meta.description = None;
self.cal.description = None;
Ok(())
}
CalendarPropName::CalendarTimezone | CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None;
CalendarPropName::CalendarTimezone => {
self.cal.timezone = None;
Ok(())
}
CalendarPropName::TimezoneServiceSet => Err(rustical_dav::Error::PropReadOnly),
CalendarPropName::CalendarTimezoneId => {
self.cal.timezone_id = None;
Ok(())
}
CalendarPropName::CalendarOrder => {
self.cal.meta.order = 0;
self.cal.order = 0;
Ok(())
}
CalendarPropName::SupportedCalendarComponentSet => {
@@ -292,32 +284,19 @@ impl Resource for CalendarResource {
CalendarPropName::MaxDateTime => Err(rustical_dav::Error::PropReadOnly),
},
CalendarPropWrapperName::SyncToken(prop) => SyncTokenExtension::remove_prop(self, prop),
CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
// CalendarPropWrapperName::DavPush(prop) => DavPushExtension::remove_prop(self, prop),
CalendarPropWrapperName::Common(prop) => {
CommonPropertiesExtension::remove_prop(self, prop)
}
}
}
fn get_displayname(&self) -> Option<&str> {
self.cal.meta.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.cal.meta.displayname = name;
Ok(())
}
fn get_owner(&self) -> Option<&str> {
Some(&self.cal.principal)
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
if self.cal.subscription_url.is_some() {
return Ok(UserPrivilegeSet::owner_write_properties(
user.is_principal(&self.cal.principal),
));
}
if self.read_only {
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
if self.cal.subscription_url.is_some() || self.read_only {
return Ok(UserPrivilegeSet::owner_read(
user.is_principal(&self.cal.principal),
));
@@ -328,15 +307,3 @@ impl Resource for CalendarResource {
))
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_tzdb_version() {
// Ensure that both chrono_tz and vzic_rs use the same tzdb version
assert_eq!(
chrono_tz::IANA_TZDB_VERSION,
vtimezones_rs::IANA_TZDB_VERSION
);
}
}

View File

@@ -1,7 +1,5 @@
use crate::calendar::methods::get::route_get;
use crate::calendar::methods::import::route_import;
use crate::calendar::methods::mkcalendar::route_mkcalendar;
use crate::calendar::methods::post::route_post;
use crate::calendar::methods::report::route_report_calendar;
use crate::calendar::resource::CalendarResource;
use crate::calendar_object::CalendarObjectResourceService;
@@ -14,7 +12,7 @@ use axum::handler::Handler;
use axum::response::Response;
use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_store::{CalendarStore, SubscriptionStore};
use std::convert::Infallible;
use std::sync::Arc;
@@ -49,23 +47,19 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type PathComponents = (String, String); // principal, calendar_id
type Resource = CalendarResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, webdav-push";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
async fn get_resource(
&self,
(principal, cal_id): &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Error> {
let calendar = self
.cal_store
.get_calendar(principal, cal_id, show_deleted)
.await?;
let calendar = self.cal_store.get_calendar(principal, cal_id).await?;
Ok(CalendarResource {
cal: calendar,
read_only: self.cal_store.is_read_only(cal_id),
read_only: self.cal_store.is_read_only(),
})
}
@@ -132,20 +126,6 @@ impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarResourceSer
})
}
fn post() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_post::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn import() -> Option<rustical_dav::resource::MethodFunction<Self>> {
Some(|state, req| {
let mut service = Handler::with_state(route_import::<C, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{
Some(|state, req| {

View File

@@ -1,222 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop>
<calendar-color xmlns="http://apple.com/ns/ical/"/>
<calendar-description xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-timezone xmlns="urn:ietf:params:xml:ns:caldav"/>
<timezone-service-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-timezone-id xmlns="urn:ietf:params:xml:ns:caldav"/>
<calendar-order xmlns="http://apple.com/ns/ical/"/>
<supported-calendar-component-set xmlns="urn:ietf:params:xml:ns:caldav"/>
<supported-calendar-data xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-resource-size xmlns="DAV:"/>
<supported-report-set xmlns="DAV:"/>
<source xmlns="http://calendarserver.org/ns/"/>
<min-date-time xmlns="urn:ietf:params:xml:ns:caldav"/>
<max-date-time xmlns="urn:ietf:params:xml:ns:caldav"/>
<sync-token xmlns="DAV:"/>
<getctag xmlns="http://calendarserver.org/ns/"/>
<transports xmlns="https://bitfire.at/webdav-push"/>
<topic xmlns="https://bitfire.at/webdav-push"/>
<supported-triggers xmlns="https://bitfire.at/webdav-push"/>
<resourcetype xmlns="DAV:"/>
<displayname xmlns="DAV:"/>
<current-user-principal xmlns="DAV:"/>
<current-user-privilege-set xmlns="DAV:"/>
<owner xmlns="DAV:"/>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>
<?xml version="1.0" encoding="utf-8"?>
<response xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav" xmlns:CS="http://calendarserver.org/ns/" xmlns:PUSH="https://bitfire.at/webdav-push">
<href>/caldav/principal/user/calendar/</href>
<propstat>
<prop>
<CAL:calendar-timezone>BEGIN:VCALENDAR
PRODID:-//github.com/lennart-k/vzic-rs//RustiCal Calendar server//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/Berlin
LAST-MODIFIED:20250723T190331Z
X-LIC-LOCATION:Europe/Berlin
X-PROLEPTIC-TZNAME:LMT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+005328
TZOFFSETTO:+0100
DTSTART:18930401T000000
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19160430T230000
RDATE:19400401T020000
RDATE:19430329T020000
RDATE:19460414T020000
RDATE:19470406T030000
RDATE:19480418T020000
RDATE:19490410T020000
RDATE:19800406T020000
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19161001T010000
RDATE:19421102T030000
RDATE:19431004T030000
RDATE:19441002T030000
RDATE:19451118T030000
RDATE:19461007T030000
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19170416T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=3MO;UNTIL=19180415T010000Z
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19170917T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=3MO;UNTIL=19180916T010000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19440403T020000
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1MO;UNTIL=19450402T010000Z
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEMT
TZOFFSETFROM:+0200
TZOFFSETTO:+0300
DTSTART:19450524T020000
RDATE:19470511T030000
END:DAYLIGHT
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0300
TZOFFSETTO:+0200
DTSTART:19450924T030000
RDATE:19470629T030000
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0100
TZOFFSETTO:+0100
DTSTART:19460101T000000
RDATE:19800101T000000
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19471005T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=1SU;UNTIL=19491002T010000Z
END:STANDARD
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19800928T030000
RRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=-1SU;UNTIL=19950924T010000Z
END:STANDARD
BEGIN:DAYLIGHT
TZNAME:CEST
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
DTSTART:19810329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZNAME:CET
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
DTSTART:19961027T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
END:VCALENDAR
</CAL:calendar-timezone>
<CAL:timezone-service-set>
<href>https://www.iana.org/time-zones</href>
</CAL:timezone-service-set>
<CAL:calendar-timezone-id>Europe/Berlin</CAL:calendar-timezone-id>
<calendar-order xmlns="http://apple.com/ns/ical/">0</calendar-order>
<CAL:supported-calendar-component-set>
<CAL:comp name="VEVENT"/>
<CAL:comp name="VTODO"/>
</CAL:supported-calendar-component-set>
<CAL:supported-calendar-data>
<CAL:calendar-data content-type="text/calendar" version="2.0"/>
</CAL:supported-calendar-data>
<max-resource-size>10000000</max-resource-size>
<supported-report-set>
<supported-report>
<report>
<CAL:calendar-query/>
</report>
</supported-report>
<supported-report>
<report>
<CAL:calendar-multiget/>
</report>
</supported-report>
<supported-report>
<report>
<sync-collection/>
</report>
</supported-report>
</supported-report-set>
<CAL:min-date-time>-2621430101T000000Z</CAL:min-date-time>
<CAL:max-date-time>+2621421231T235959Z</CAL:max-date-time>
<sync-token>github.com/lennart-k/rustical/ns/12</sync-token>
<CS:getctag>github.com/lennart-k/rustical/ns/12</CS:getctag>
<PUSH:transports>
<PUSH:web-push/>
</PUSH:transports>
<PUSH:topic>b28b41e9-8801-4fc5-ae29-8efb5fadeb36</PUSH:topic>
<PUSH:supported-triggers>
<PUSH:content-update>
<depth>1</depth>
</PUSH:content-update>
<PUSH:property-update>
<depth>1</depth>
</PUSH:property-update>
</PUSH:supported-triggers>
<resourcetype>
<collection/>
<CAL:calendar/>
</resourcetype>
<displayname>Calendar</displayname>
<current-user-principal>
<href>/caldav/principal/user/</href>
</current-user-principal>
<current-user-privilege-set>
<privilege>
<read/>
</privilege>
<privilege>
<read-acl/>
</privilege>
<privilege>
<read-current-user-privilege-set/>
</privilege>
</current-user-privilege-set>
<owner>
<href>/caldav/principal/user/</href>
</owner>
</prop>
<status>HTTP/1.1 200 OK</status>
</propstat>
</response>

View File

@@ -1,11 +0,0 @@
[
{
"id": "user",
"displayname": null,
"principal_type": "individual",
"password": null,
"memberships": [
"group"
]
}
]

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><propname/></propfind>
<?xml version="1.0" encoding="UTF-8"?>
<propfind xmlns="DAV:"><allprop/></propfind>

View File

@@ -1,42 +0,0 @@
[
{
"cal": {
"principal": "user",
"id": "calendar",
"displayname": "Calendar",
"order": 0,
"description": null,
"color": null,
"timezone_id": "Europe/Berlin",
"deleted_at": null,
"synctoken": 12,
"subscription_url": null,
"push_topic": "b28b41e9-8801-4fc5-ae29-8efb5fadeb36",
"components": [
"VEVENT",
"VTODO"
]
},
"read_only": true
},
{
"cal": {
"principal": "user",
"id": "calendar",
"displayname": "Calendar",
"order": 0,
"description": null,
"color": null,
"timezone_id": "Europe/Berlin",
"deleted_at": null,
"synctoken": 12,
"subscription_url": null,
"push_topic": "b28b41e9-8801-4fc5-ae29-8efb5fadeb36",
"components": [
"VEVENT",
"VTODO"
]
},
"read_only": true
}
]

View File

@@ -1,47 +0,0 @@
use crate::{CalDavPrincipalUri, calendar::resource::CalendarResource};
use rustical_dav::resource::Resource;
use rustical_store::auth::Principal;
use rustical_xml::XmlSerializeRoot;
use serde_json::from_str;
#[tokio::test]
async fn test_propfind() {
let requests: Vec<_> = include_str!("./test_files/propfind.requests")
.trim()
.split("\n\n")
.collect();
let principals: Vec<Principal> =
from_str(include_str!("./test_files/propfind.principals.json")).unwrap();
let resources: Vec<CalendarResource> =
from_str(include_str!("./test_files/propfind.resources.json")).unwrap();
let outputs: Vec<_> = include_str!("./test_files/propfind.outputs")
.trim()
.split("\n\n")
.collect();
for principal in principals {
for ((request, resource), &expected_output) in requests.iter().zip(&resources).zip(&outputs)
{
let propfind = CalendarResource::parse_propfind(request).unwrap();
let response = resource
.propfind(
&format!("/caldav/principal/{}/{}", principal.id, resource.cal.id),
&propfind.prop,
propfind.include.as_ref(),
&CalDavPrincipalUri("/caldav"),
&principal,
)
.unwrap();
let expected_output = expected_output.trim();
let output = response
.serialize_to_string()
.unwrap()
.trim()
.replace("\r\n", "\n");
println!("{output}");
println!("{}, {} \n\n\n", output.len(), expected_output.len());
assert_eq!(output, expected_output);
}
}
}

View File

@@ -6,12 +6,12 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, Method, StatusCode};
use http::{HeaderMap, StatusCode};
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use std::str::FromStr;
use tracing::{debug, error, instrument};
use tracing::instrument;
#[instrument(skip(cal_store))]
pub async fn get_event<C: CalendarStore>(
@@ -21,33 +21,26 @@ pub async fn get_event<C: CalendarStore>(
object_id,
}): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal,
method: Method,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store
.get_calendar(&principal, &calendar_id, false)
.await?;
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized);
}
let event = cal_store
.get_object(&principal, &calendar_id, &object_id, false)
.get_object(&principal, &calendar_id, &object_id)
.await?;
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&event.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/calendar").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
}
Ok(resp.body(Body::new(event.get_ics().to_owned())).unwrap())
}
#[instrument(skip(cal_store))]
@@ -58,7 +51,7 @@ pub async fn put_event<C: CalendarStore>(
object_id,
}): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: Principal,
user: User,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap,
body: String,
@@ -78,21 +71,12 @@ pub async fn put_event<C: CalendarStore>(
true
};
let object = match CalendarObject::from_ics(body.clone()) {
let object = match CalendarObject::from_ics(object_id, body) {
Ok(obj) => obj,
Err(_) => {
debug!("invalid calendar data:\n{body}");
return Err(Error::PreconditionFailed(Precondition::ValidCalendarData));
}
};
if object.get_id() != object_id {
error!(
"Calendar object UID and file name not matching: UID={}, filename={}",
object.get_id(),
object_id
);
return Err(Error::PreconditionFailed(Precondition::MatchingUid));
}
cal_store
.put_object(principal, calendar_id, object, overwrite)
.await?;

View File

@@ -8,7 +8,7 @@ use rustical_dav::{
xml::Resourcetype,
};
use rustical_ical::CalendarObject;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
#[derive(Clone, From, Into)]
pub struct CalendarObjectResource {
@@ -25,11 +25,9 @@ impl ResourceName for CalendarObjectResource {
impl Resource for CalendarObjectResource {
type Prop = CalendarObjectPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
false
}
const IS_COLLECTION: bool = false;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -38,7 +36,7 @@ impl Resource for CalendarObjectResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &CalendarObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -68,10 +66,6 @@ impl Resource for CalendarObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
None
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}
@@ -80,7 +74,7 @@ impl Resource for CalendarObjectResource {
Some(self.object.get_etag())
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal),
))

View File

@@ -9,7 +9,7 @@ use async_trait::async_trait;
use axum::{extract::Request, handler::Handler, response::Response};
use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::{CalendarStore, auth::Principal};
use rustical_store::{CalendarStore, auth::User};
use serde::{Deserialize, Deserializer};
use std::{convert::Infallible, sync::Arc};
use tower::Service;
@@ -46,7 +46,7 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
type Resource = CalendarObjectResource;
type MemberType = CalendarObjectResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
@@ -58,11 +58,10 @@ impl<C: CalendarStore> ResourceService for CalendarObjectResourceService<C> {
calendar_id,
object_id,
}: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
let object = self
.cal_store
.get_object(principal, calendar_id, object_id, show_deleted)
.get_object(principal, calendar_id, object_id)
.await?;
Ok(CalendarObjectResource {
object,

View File

@@ -0,0 +1,64 @@
use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;
mod service;
pub use service::*;
mod prop;
pub use prop::*;
#[derive(Clone)]
pub struct CalendarSetResource {
pub(crate) principal: String,
pub(crate) read_only: bool,
pub(crate) name: &'static str,
}
impl ResourceName for CalendarSetResource {
fn get_name(&self) -> String {
self.name.to_owned()
}
}
impl Resource for CalendarSetResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = User;
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner(
Some(rustical_dav::namespace::NS_DAV),
"collection",
)])
}
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
PrincipalPropWrapperName::Common(prop) => PrincipalPropWrapper::Common(
<Self as CommonPropertiesExtension>::get_prop(self, puri, user, prop)?,
),
})
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(if self.read_only {
UserPrivilegeSet::owner_read(user.is_principal(&self.principal))
} else {
UserPrivilegeSet::owner_only(user.is_principal(&self.principal))
})
}
}

View File

@@ -0,0 +1,8 @@
use rustical_dav::extensions::CommonPropertiesProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}

View File

@@ -0,0 +1,84 @@
use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use crate::calendar_set::CalendarSetResource;
use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait;
use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::User;
use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc;
pub struct CalendarSetResourceService<C: CalendarStore, S: SubscriptionStore> {
name: &'static str,
cal_store: Arc<C>,
sub_store: Arc<S>,
}
impl<C: CalendarStore, S: SubscriptionStore> Clone for CalendarSetResourceService<C, S> {
fn clone(&self) -> Self {
Self {
name: self.name,
cal_store: self.cal_store.clone(),
sub_store: self.sub_store.clone(),
}
}
}
impl<C: CalendarStore, S: SubscriptionStore> CalendarSetResourceService<C, S> {
pub fn new(name: &'static str, cal_store: Arc<C>, sub_store: Arc<S>) -> Self {
Self {
name,
cal_store,
sub_store,
}
}
}
#[async_trait]
impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarSetResourceService<C, S> {
type PathComponents = (String,);
type MemberType = CalendarResource;
type Resource = CalendarSetResource;
type Error = Error;
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, extended-mkcol, calendar-access";
async fn get_resource(
&self,
(principal,): &Self::PathComponents,
) -> Result<Self::Resource, Self::Error> {
Ok(CalendarSetResource {
principal: principal.to_owned(),
read_only: self.cal_store.is_read_only(),
name: self.name,
})
}
async fn get_members(
&self,
(principal,): &Self::PathComponents,
) -> Result<Vec<Self::MemberType>, Self::Error> {
let calendars = self.cal_store.get_calendars(principal).await?;
Ok(calendars
.into_iter()
.map(|cal| CalendarResource {
cal,
read_only: self.cal_store.is_read_only(),
})
.collect())
}
fn axum_router<State: Send + Sync + Clone + 'static>(self) -> axum::Router<State> {
Router::new()
.nest(
"/{calendar_id}",
CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone())
.axum_router(),
)
.route_service("/", self.axum_service())
}
}
impl<C: CalendarStore, S: SubscriptionStore> AxumMethods for CalendarSetResourceService<C, S> {}

View File

@@ -12,8 +12,6 @@ pub enum Precondition {
#[error("valid-calendar-data")]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
ValidCalendarData,
#[error("matching-uid")]
MatchingUid,
}
impl IntoResponse for Precondition {
@@ -85,12 +83,6 @@ impl Error {
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}");
}
(self.status_code(), self.to_string()).into_response()
}
}

View File

@@ -1,48 +1,63 @@
use axum::response::Redirect;
use axum::routing::any;
use axum::{Extension, Router};
use derive_more::Constructor;
use principal::PrincipalResourceService;
use rustical_dav::resource::{PrincipalUri, ResourceService};
use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore};
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{AddressbookStore, CalendarStore, ContactBirthdayStore, SubscriptionStore};
use std::sync::Arc;
pub mod calendar;
pub mod calendar_object;
pub mod calendar_set;
pub mod error;
pub mod principal;
// mod subscription;
pub use error::Error;
#[derive(Debug, Clone, Constructor)]
pub struct CalDavPrincipalUri(&'static str);
impl PrincipalUri for CalDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String {
format!("{}{}/", self.principal_collection(), principal)
format!("{}/principal/{}/", self.0, principal)
}
}
pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: SubscriptionStore>(
pub fn caldav_router<
AP: AuthenticationProvider,
AS: AddressbookStore,
C: CalendarStore,
S: SubscriptionStore,
>(
prefix: &'static str,
auth_provider: Arc<AP>,
store: Arc<C>,
addr_store: Arc<AS>,
subscription_store: Arc<S>,
simplified_home_set: bool,
) -> Router {
Router::new().nest(
prefix,
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(PrincipalResourceService {
auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(),
cal_store: store.clone(),
simplified_home_set,
})
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),
)
let birthday_store = Arc::new(ContactBirthdayStore::new(addr_store));
let principal_service = PrincipalResourceService {
auth_provider: auth_provider.clone(),
sub_store: subscription_store.clone(),
birthday_store: birthday_store.clone(),
cal_store: store.clone(),
};
Router::new()
.nest(
prefix,
RootResourceService::<_, User, CalDavPrincipalUri>::new(principal_service.clone())
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),
)
.route(
"/.well-known/caldav",
any(async || Redirect::permanent(prefix)),
)
}

View File

@@ -2,24 +2,18 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
};
use rustical_store::auth::Principal;
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;
mod service;
pub use service::*;
mod prop;
pub use prop::*;
#[cfg(test)]
pub mod tests;
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct PrincipalResource {
principal: Principal,
members: Vec<String>,
// If true only return the principal as the calendar home set, otherwise also groups
simplified_home_set: bool,
principal: User,
home_set: &'static [&'static str],
}
impl ResourceName for PrincipalResource {
@@ -31,11 +25,9 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
true
}
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -47,58 +39,42 @@ impl Resource for PrincipalResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
let principal_url = puri.principal_uri(&self.principal.id);
let home_set = CalendarHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal))
.flat_map(|principal_url| {
self.home_set.iter().map(move |&home_name| {
HrefElement::new(format!("{}{}/", &principal_url, home_name))
})
})
.collect(),
);
Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::CalendarUserType => {
PrincipalProp::CalendarUserType(self.principal.principal_type.to_owned())
}
PrincipalPropName::Displayname => PrincipalProp::Displayname(
self.principal
.displayname
.to_owned()
.unwrap_or(self.principal.id.to_owned()),
),
PrincipalPropName::PrincipalUrl => {
PrincipalProp::PrincipalUrl(principal_url.into())
}
PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(
CalendarHomeSet(if self.simplified_home_set {
vec![principal_url.into()]
} else {
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect()
}),
),
PrincipalPropName::CalendarHomeSet => PrincipalProp::CalendarHomeSet(home_set),
PrincipalPropName::CalendarUserAddressSet => {
PrincipalProp::CalendarUserAddressSet(principal_url.into())
}
PrincipalPropName::GroupMemberSet => {
PrincipalProp::GroupMemberSet(GroupMemberSet(
self.members
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::GroupMembership => {
PrincipalProp::GroupMembership(GroupMembership(
self.principal
.memberships_without_self()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::AlternateUriSet => PrincipalProp::AlternateUriSet,
// PrincipalPropName::PrincipalCollectionSet => {
// PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
// }
PrincipalPropName::SupportedReportSet => {
PrincipalProp::SupportedReportSet(SupportedReportSet::all())
}
})
}
PrincipalPropWrapperName::Common(prop) => PrincipalPropWrapper::Common(
@@ -107,21 +83,12 @@ impl Resource for PrincipalResource {
})
}
fn get_displayname(&self) -> Option<&str> {
Some(
self.principal
.displayname
.as_ref()
.unwrap_or(&self.principal.id),
)
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id)
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_read(
user.is_principal(&self.principal.id),
))
}

View File

@@ -1,14 +1,13 @@
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet},
};
use rustical_store::auth::PrincipalType;
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_store::auth::user::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// Scheduling Extensions to CalDAV (RFC 6638)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV", skip_deserializing)]
CalendarUserType(PrincipalType),
@@ -16,27 +15,14 @@ pub enum PrincipalProp {
CalendarUserAddressSet(HrefElement),
// WebDAV Access Control (RFC 3744)
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = b"principal-URL")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "alternate-URI-set")]
AlternateUriSet,
// #[xml(ns = "rustical_dav::namespace::NS_DAV")]
// PrincipalCollectionSet(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet<ReportMethod>),
// CalDAV (RFC 4791)
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
CalendarHomeSet(CalendarHomeSet),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {
@@ -44,9 +30,5 @@ pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}
#[derive(XmlSerialize, PartialEq, Clone, VariantArray)]
pub enum ReportMethod {
// We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalMatch,
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);

View File

@@ -1,11 +1,10 @@
use crate::calendar::CalendarResourceService;
use crate::calendar::resource::CalendarResource;
use crate::calendar_set::{CalendarSetResource, CalendarSetResourceService};
use crate::principal::PrincipalResource;
use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait;
use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc;
@@ -14,36 +13,36 @@ pub struct PrincipalResourceService<
AP: AuthenticationProvider,
S: SubscriptionStore,
CS: CalendarStore,
BS: CalendarStore,
> {
pub(crate) auth_provider: Arc<AP>,
pub(crate) sub_store: Arc<S>,
pub(crate) cal_store: Arc<CS>,
// If true only return the principal as the calendar home set, otherwise also groups
pub(crate) simplified_home_set: bool,
pub(crate) birthday_store: Arc<BS>,
}
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Clone
for PrincipalResourceService<AP, S, CS>
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore> Clone
for PrincipalResourceService<AP, S, CS, BS>
{
fn clone(&self) -> Self {
Self {
auth_provider: self.auth_provider.clone(),
sub_store: self.sub_store.clone(),
cal_store: self.cal_store.clone(),
simplified_home_set: self.simplified_home_set,
birthday_store: self.birthday_store.clone(),
}
}
}
#[async_trait]
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> ResourceService
for PrincipalResourceService<AP, S, CS>
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore>
ResourceService for PrincipalResourceService<AP, S, CS, BS>
{
type PathComponents = (String,);
type MemberType = CalendarResource;
type MemberType = CalendarSetResource;
type Resource = PrincipalResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
@@ -51,7 +50,6 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
async fn get_resource(
&self,
(principal,): &Self::PathComponents,
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
let user = self
.auth_provider
@@ -59,9 +57,8 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
simplified_home_set: self.simplified_home_set,
home_set: &["calendar", "birthdays"],
})
}
@@ -69,29 +66,45 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
&self,
(principal,): &Self::PathComponents,
) -> Result<Vec<Self::MemberType>, Self::Error> {
let calendars = self.cal_store.get_calendars(principal).await?;
Ok(calendars
.into_iter()
.map(|cal| CalendarResource {
read_only: self.cal_store.is_read_only(&cal.id),
cal,
})
.collect())
Ok(vec![
CalendarSetResource {
name: "calendar",
principal: principal.to_owned(),
read_only: false,
},
CalendarSetResource {
name: "birthdays",
principal: principal.to_owned(),
read_only: true,
},
])
}
fn axum_router<State: Send + Sync + Clone + 'static>(self) -> axum::Router<State> {
Router::new()
.nest(
"/{calendar_id}",
CalendarResourceService::new(self.cal_store.clone(), self.sub_store.clone())
.axum_router(),
"/calendar",
CalendarSetResourceService::new(
"calendar",
self.cal_store.clone(),
self.sub_store.clone(),
)
.axum_router(),
)
.nest(
"/birthdays",
CalendarSetResourceService::new(
"birthdays",
self.birthday_store.clone(),
self.sub_store.clone(),
)
.axum_router(),
)
.route_service("/", self.axum_service())
}
}
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> AxumMethods
for PrincipalResourceService<AP, S, CS>
impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore, BS: CalendarStore>
AxumMethods for PrincipalResourceService<AP, S, CS, BS>
{
}

View File

@@ -1,92 +0,0 @@
use std::sync::Arc;
use crate::{
CalDavPrincipalUri,
principal::{PrincipalResource, PrincipalResourceService},
};
use rstest::rstest;
use rustical_dav::resource::{Resource, ResourceService};
use rustical_store::auth::{Principal, PrincipalType::Individual};
use rustical_store_sqlite::{
SqliteStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
};
use rustical_xml::XmlSerializeRoot;
#[rstest]
#[tokio::test]
async fn test_principal_resource(
#[from(get_test_calendar_store)]
#[future]
cal_store: SqliteCalendarStore,
#[from(get_test_principal_store)]
#[future]
auth_provider: SqlitePrincipalStore,
#[from(get_test_subscription_store)]
#[future]
sub_store: SqliteStore,
) {
let service = PrincipalResourceService {
cal_store: Arc::new(cal_store.await),
sub_store: Arc::new(sub_store.await),
auth_provider: Arc::new(auth_provider.await),
simplified_home_set: false,
};
// We don't have any calendars here
assert!(
service
.get_members(&("user".to_owned(),))
.await
.unwrap()
.is_empty()
);
assert!(matches!(
service
.get_resource(&("invalid-user".to_owned(),), true)
.await,
Err(crate::Error::NotFound)
));
let _principal_resource = service
.get_resource(&("user".to_owned(),), true)
.await
.unwrap();
}
#[tokio::test]
async fn test_propfind() {
let propfind = PrincipalResource::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
let principal = Principal {
id: "user".to_string(),
displayname: None,
principal_type: Individual,
password: None,
memberships: vec!["group".to_string()],
};
let resource = PrincipalResource {
principal: principal.clone(),
members: vec![],
simplified_home_set: false,
};
let response = resource
.propfind(
&format!("/caldav/principal/{}", principal.id),
&propfind.prop,
propfind.include.as_ref(),
&CalDavPrincipalUri("/caldav"),
&principal,
)
.unwrap();
let _output = response.serialize_to_string().unwrap();
}

View File

@@ -0,0 +1,33 @@
use std::sync::Arc;
use actix_web::{
HttpResponse,
web::{self, Data, Path},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::SubscriptionStore;
use rustical_xml::{XmlRootTag, XmlSerialize};
use crate::calendar::resource::CalendarProp;
async fn handle_delete<S: SubscriptionStore>(
store: Data<S>,
path: Path<String>,
) -> Result<HttpResponse, rustical_store::Error> {
let id = path.into_inner();
store.delete_subscription(&id).await?;
Ok(HttpResponse::NoContent().body("Unregistered"))
}
pub fn subscription_resource<S: SubscriptionStore>(sub_store: Arc<S>) -> actix_web::Resource {
web::resource("/subscription/{id}")
.app_data(Data::from(sub_store))
.name("subscription")
.delete(handle_delete::<S>)
}
#[derive(XmlSerialize, XmlRootTag)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
pub struct PushMessage {
propstat: PropstatElement<CalendarProp>,
}

View File

@@ -4,7 +4,6 @@ version.workspace = true
edition.workspace = true
description.workspace = true
repository.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -32,5 +31,3 @@ http.workspace = true
tower-http.workspace = true
percent-encoding.workspace = true
ical.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -7,13 +7,12 @@ use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use axum_extra::TypedHeader;
use axum_extra::headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::Method;
use http::{HeaderMap, StatusCode};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_ical::AddressObject;
use rustical_store::AddressbookStore;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use std::str::FromStr;
use tracing::instrument;
@@ -25,8 +24,7 @@ pub async fn get_object<AS: AddressbookStore>(
object_id,
}): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal,
method: Method,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
@@ -51,11 +49,7 @@ pub async fn get_object<AS: AddressbookStore>(
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ETag::from_str(&object.get_etag()).unwrap());
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
}
Ok(resp.body(Body::new(object.get_vcf().to_owned())).unwrap())
}
#[instrument(skip(addr_store, body))]
@@ -66,7 +60,7 @@ pub async fn put_object<AS: AddressbookStore>(
object_id,
}): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: Principal,
user: User,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap,
body: String,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
xml::Resourcetype,
};
use rustical_ical::AddressObject;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
#[derive(Clone, From, Into)]
pub struct AddressObjectResource {
@@ -30,11 +30,9 @@ impl ResourceName for AddressObjectResource {
impl Resource for AddressObjectResource {
type Prop = AddressObjectPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
false
}
const IS_COLLECTION: bool = false;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -43,7 +41,7 @@ impl Resource for AddressObjectResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &AddressObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -66,10 +64,6 @@ impl Resource for AddressObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name()
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}
@@ -78,7 +72,7 @@ impl Resource for AddressObjectResource {
Some(self.object.get_etag())
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal),
))

View File

@@ -5,7 +5,7 @@ use axum::{extract::Request, handler::Handler, response::Response};
use derive_more::derive::Constructor;
use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::{AddressbookStore, auth::Principal};
use rustical_store::{AddressbookStore, auth::User};
use serde::{Deserialize, Deserializer};
use std::{convert::Infallible, sync::Arc};
use tower::Service;
@@ -37,7 +37,7 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
type Resource = AddressObjectResource;
type MemberType = AddressObjectResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -49,11 +49,10 @@ impl<AS: AddressbookStore> ResourceService for AddressObjectResourceService<AS>
addressbook_id,
object_id,
}: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
let object = self
.addr_store
.get_object(principal, addressbook_id, object_id, show_deleted)
.get_object(principal, addressbook_id, object_id, false)
.await?;
Ok(AddressObjectResource {
object,

View File

@@ -5,12 +5,12 @@ use axum::body::Body;
use axum::extract::{Path, State};
use axum::response::Response;
use axum_extra::headers::{ContentType, HeaderMapExt};
use http::{HeaderValue, Method, StatusCode, header};
use http::{HeaderValue, StatusCode, header};
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_ical::AddressObject;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::str::FromStr;
use tracing::instrument;
@@ -19,8 +19,7 @@ use tracing::instrument;
pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: Principal,
method: Method,
user: User,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
@@ -47,7 +46,7 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
let mut resp = Response::builder().status(StatusCode::OK);
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::from_str("text/vcard").unwrap());
let filename = format!("{principal}_{addressbook_id}.vcf");
let filename = format!("{}_{}.vcf", principal, addressbook_id);
let filename = utf8_percent_encode(&filename, CONTROLS);
hdrs.insert(
header::CONTENT_DISPOSITION,
@@ -56,9 +55,5 @@ pub async fn route_get<AS: AddressbookStore, S: SubscriptionStore>(
))
.unwrap(),
);
if matches!(method, Method::HEAD) {
Ok(resp.body(Body::empty()).unwrap())
} else {
Ok(resp.body(Body::new(vcf)).unwrap())
}
Ok(resp.body(Body::new(vcf)).unwrap())
}

View File

@@ -1,67 +0,0 @@
use std::io::BufReader;
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use ical::{
parser::{Component, ComponentMut, vcard},
property::Property,
};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use tracing::instrument;
#[instrument(skip(resource_service))]
pub async fn route_import<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let parser = vcard::VcardParser::new(BufReader::new(body.as_bytes()));
let mut objects = vec![];
for res in parser {
let mut card = res.unwrap();
let uid = card.get_uid();
if uid.is_none() {
let mut card_mut = card.mutable();
card_mut.set_property(Property {
name: "UID".to_owned(),
value: Some(uuid::Uuid::new_v4().to_string()),
params: None,
});
card = card_mut.verify().unwrap();
}
objects.push(card.try_into().unwrap());
}
if objects.is_empty() {
return Ok((StatusCode::BAD_REQUEST, "empty addressbook data").into_response());
}
let addressbook = Addressbook {
principal,
id: addressbook_id,
displayname: None,
description: None,
deleted_at: None,
synctoken: 0,
push_topic: uuid::Uuid::new_v4().to_string(),
};
let addr_store = resource_service.addr_store;
addr_store
.import_addressbook(addressbook, objects, false)
.await?;
Ok(StatusCode::OK.into_response())
}

View File

@@ -4,7 +4,7 @@ use axum::{
response::{IntoResponse, Response},
};
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument;
@@ -22,7 +22,7 @@ pub struct MkcolAddressbookProp {
resourcetype: Option<Resourcetype>,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
displayname: Option<String>,
#[xml(rename = "addressbook-description")]
#[xml(rename = b"addressbook-description")]
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
description: Option<String>,
}
@@ -34,7 +34,7 @@ pub struct PropElement<T: XmlDeserialize> {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug, PartialEq)]
#[xml(root = "mkcol")]
#[xml(root = b"mkcol")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
struct MkcolRequest {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
@@ -44,7 +44,7 @@ struct MkcolRequest {
#[instrument(skip(addr_store))]
pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
user: Principal,
user: User,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
@@ -52,10 +52,8 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let mut request = MkcolRequest::parse_str(&body)?.set.prop;
if let Some("") = request.displayname.as_deref() {
request.displayname = None
}
let request = MkcolRequest::parse_str(&body)?;
let request = request.set.prop;
let addressbook = Addressbook {
id: addressbook_id.to_owned(),
@@ -88,8 +86,15 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
}
}
addr_store.insert_addressbook(addressbook).await?;
Ok(StatusCode::CREATED.into_response())
match addr_store.insert_addressbook(addressbook).await {
// TODO: The spec says we should return a mkcol-response.
// However, it works without one but breaks on iPadOS when using an empty one :)
Ok(()) => Ok(StatusCode::CREATED.into_response()),
Err(err) => {
dbg!(err.to_string());
Err(err.into())
}
}
}
#[cfg(test)]

View File

@@ -1,5 +1,5 @@
pub mod get;
pub mod import;
pub mod mkcol;
pub mod post;
// pub mod post;
pub mod get;
pub mod put;
pub mod report;

View File

@@ -1,40 +1,33 @@
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use crate::addressbook::resource::AddressbookResource;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{HeaderMap, HeaderValue, StatusCode, header};
use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use crate::addressbook::resource::AddressbookResourceService;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
use rustical_dav_push::register::PushRegister;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument;
use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(skip(resource_service))]
pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addr_id)): Path<(String, String)>,
user: Principal,
State(resource_service): State<AddressbookResourceService<AS, S>>,
#[instrument(parent = root_span.id(), skip(resource_service, root_span, req))]
pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>,
body: String,
) -> Result<Response, Error> {
user: User,
resource_service: Data<AddressbookResourceService<A, S>>,
root_span: RootSpan,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
let (principal, addressbook_id) = path.into_inner();
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let addressbook = resource_service
.addr_store
.get_addressbook(&principal, &addr_id, false)
.get_addressbook(&principal, &addressbook_id, false)
.await?;
let addressbook_resource = AddressbookResource(addressbook);
if !addressbook_resource
.get_user_privileges(&user)?
.has(&UserPrivilege::Read)
{
return Err(Error::Unauthorized);
}
let request = PushRegister::parse_str(&body)?;
let sub_id = uuid::Uuid::new_v4().to_string();
@@ -51,7 +44,7 @@ pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
.web_push_subscription
.push_resource
.to_owned(),
topic: addressbook_resource.0.push_topic,
topic: addressbook.push_topic,
expiration: expires.naive_local(),
public_key: request
.subscription
@@ -70,17 +63,13 @@ pub async fn route_post<AS: AddressbookStore, S: SubscriptionStore>(
.upsert_subscription(subscription)
.await?;
// TODO: make nicer
let location = format!("/push_subscription/{sub_id}");
Ok((
StatusCode::CREATED,
HeaderMap::from_iter([
(header::LOCATION, HeaderValue::from_str(&location).unwrap()),
(
header::EXPIRES,
HeaderValue::from_str(&expires.to_rfc2822()).unwrap(),
),
]),
)
.into_response())
let location = req
.resource_map()
.url_for(&req, "subscription", &[sub_id])
.unwrap();
Ok(HttpResponse::Created()
.append_header((header::LOCATION, location.to_string()))
.append_header((header::EXPIRES, expires.to_rfc2822()))
.finish())
}

View File

@@ -0,0 +1,47 @@
use crate::Error;
use crate::addressbook::AddressbookResourceService;
use axum::response::IntoResponse;
use axum::{
extract::{Path, State},
response::Response,
};
use http::StatusCode;
use ical::VcardParser;
use rustical_ical::AddressObject;
use rustical_store::Addressbook;
use rustical_store::{AddressbookStore, SubscriptionStore, auth::User};
use tracing::instrument;
#[instrument(skip(addr_store))]
pub async fn route_put<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
user: User,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let mut objects = vec![];
for object in VcardParser::new(body.as_bytes()) {
let object = object.map_err(rustical_ical::Error::from)?;
objects.push(AddressObject::try_from(object)?);
}
let addressbook = Addressbook {
id: addressbook_id.clone(),
principal: principal.clone(),
displayname: None,
description: None,
deleted_at: None,
synctoken: Default::default(),
push_topic: uuid::Uuid::new_v4().to_string(),
};
addr_store
.import_addressbook(principal.clone(), addressbook, objects)
.await?;
Ok(StatusCode::CREATED.into_response())
}

View File

@@ -10,7 +10,7 @@ use rustical_dav::{
xml::{MultistatusElement, PropfindType, multistatus::ResponseElement},
};
use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, auth::Principal};
use rustical_store::{AddressbookStore, auth::User};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -58,13 +58,12 @@ pub async fn get_objects_addressbook_multiget<AS: AddressbookStore>(
Ok((result, not_found))
}
#[allow(clippy::too_many_arguments)]
pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
addr_multiget: &AddressbookMultigetRequest,
prop: &PropfindType<AddressObjectPropWrapperName>,
path: &str,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
principal: &str,
cal_id: &str,
addr_store: &AS,
@@ -81,7 +80,7 @@ pub async fn handle_addressbook_multiget<AS: AddressbookStore>(
object,
principal: principal.to_owned(),
}
.propfind(&path, prop, None, puri, user)?,
.propfind(&path, prop, puri, user)?,
);
}

View File

@@ -9,7 +9,7 @@ use axum::{
response::IntoResponse,
};
use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::User};
use rustical_xml::{XmlDeserialize, XmlDocument};
use sync_collection::handle_sync_collection;
use tracing::instrument;
@@ -37,7 +37,7 @@ impl ReportRequest {
#[instrument(skip(addr_store))]
pub async fn route_report_addressbook<AS: AddressbookStore, S: SubscriptionStore>(
Path((principal, addressbook_id)): Path<(String, String)>,
user: Principal,
user: User,
OriginalUri(uri): OriginalUri,
Extension(puri): Extension<CardDavPrincipalUri>,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
};
use rustical_store::{
AddressbookStore,
auth::Principal,
auth::User,
synctoken::{format_synctoken, parse_synctoken},
};
@@ -21,7 +21,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
sync_collection: &SyncCollectionRequest<AddressObjectPropWrapperName>,
path: &str,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
principal: &str,
addressbook_id: &str,
addr_store: &AS,
@@ -39,7 +39,7 @@ pub async fn handle_sync_collection<AS: AddressbookStore>(
object,
principal: principal.to_owned(),
}
.propfind(&path, &sync_collection.prop, None, puri, user)?,
.propfind(&path, &sync_collection.prop, puri, user)?,
);
}

View File

@@ -1,21 +1,21 @@
use rustical_dav::{
extensions::{CommonPropertiesProp, SyncTokenExtensionProp},
xml::SupportedReportSet,
};
use rustical_dav::extensions::{CommonPropertiesProp, SyncTokenExtensionProp};
use rustical_dav_push::DavPushExtensionProp;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "AddressbookPropName")]
pub enum AddressbookProp {
// WebDAV (RFC 2518)
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(Option<String>),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookDescription(Option<String>),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)]
SupportedAddressData(SupportedAddressData),
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
}
@@ -60,10 +60,37 @@ impl Default for SupportedAddressData {
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub enum ReportMethod {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookMultiget,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SyncCollection,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportWrapper {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
report: ReportMethod,
}
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet {
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV", flatten)]
supported_report: &'static [SupportedReportWrapper],
}
impl Default for SupportedReportSet {
fn default() -> Self {
Self {
supported_report: &[
SupportedReportWrapper {
report: ReportMethod::AddressbookMultiget,
},
SupportedReportWrapper {
report: ReportMethod::SyncCollection,
},
],
}
}
}

View File

@@ -1,4 +1,4 @@
use super::prop::SupportedAddressData;
use super::prop::{SupportedAddressData, SupportedReportSet};
use crate::Error;
use crate::addressbook::prop::{
AddressbookProp, AddressbookPropName, AddressbookPropWrapper, AddressbookPropWrapperName,
@@ -7,10 +7,10 @@ use derive_more::derive::{From, Into};
use rustical_dav::extensions::{CommonPropertiesExtension, SyncTokenExtension};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner};
use rustical_dav_push::DavPushExtension;
use rustical_store::Addressbook;
use rustical_store::auth::Principal;
use rustical_store::auth::User;
#[derive(Clone, Debug, From, Into)]
pub struct AddressbookResource(pub(crate) Addressbook);
@@ -36,11 +36,9 @@ impl DavPushExtension for AddressbookResource {
impl Resource for AddressbookResource {
type Prop = AddressbookPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
true
}
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -52,17 +50,20 @@ impl Resource for AddressbookResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &AddressbookPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
AddressbookPropWrapperName::Addressbook(prop) => {
AddressbookPropWrapper::Addressbook(match prop {
AddressbookPropName::Displayname => {
AddressbookProp::Displayname(self.0.displayname.clone())
}
AddressbookPropName::MaxResourceSize => {
AddressbookProp::MaxResourceSize(10000000)
}
AddressbookPropName::SupportedReportSet => {
AddressbookProp::SupportedReportSet(SupportedReportSet::all())
AddressbookProp::SupportedReportSet(SupportedReportSet::default())
}
AddressbookPropName::AddressbookDescription => {
AddressbookProp::AddressbookDescription(self.0.description.to_owned())
@@ -88,6 +89,10 @@ impl Resource for AddressbookResource {
fn set_prop(&mut self, prop: Self::Prop) -> Result<(), rustical_dav::Error> {
match prop {
AddressbookPropWrapper::Addressbook(prop) => match prop {
AddressbookProp::Displayname(displayname) => {
self.0.displayname = displayname;
Ok(())
}
AddressbookProp::AddressbookDescription(description) => {
self.0.description = description;
Ok(())
@@ -108,6 +113,10 @@ impl Resource for AddressbookResource {
) -> Result<(), rustical_dav::Error> {
match prop {
AddressbookPropWrapperName::Addressbook(prop) => match prop {
AddressbookPropName::Displayname => {
self.0.displayname = None;
Ok(())
}
AddressbookPropName::AddressbookDescription => {
self.0.description = None;
Ok(())
@@ -126,19 +135,11 @@ impl Resource for AddressbookResource {
}
}
fn get_displayname(&self) -> Option<&str> {
self.0.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.0.displayname = name;
Ok(())
}
fn get_owner(&self) -> Option<&str> {
Some(&self.0.principal)
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.0.principal),
))

View File

@@ -3,8 +3,7 @@ use super::methods::report::route_report_addressbook;
use crate::address_object::AddressObjectResourceService;
use crate::address_object::resource::AddressObjectResource;
use crate::addressbook::methods::get::route_get;
use crate::addressbook::methods::import::route_import;
use crate::addressbook::methods::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait;
@@ -14,7 +13,7 @@ use axum::handler::Handler;
use axum::response::Response;
use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::Principal;
use rustical_store::auth::User;
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::convert::Infallible;
use std::sync::Arc;
@@ -51,19 +50,18 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
type PathComponents = (String, String); // principal, addressbook_id
type Resource = AddressbookResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push";
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
async fn get_resource(
&self,
(principal, addressbook_id): &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Error> {
let addressbook = self
.addr_store
.get_addressbook(principal, addressbook_id, show_deleted)
.get_addressbook(principal, addressbook_id, false)
.await
.map_err(|_e| Error::NotFound)?;
Ok(addressbook.into())
@@ -132,16 +130,9 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
})
}
fn post() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
fn put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_post::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}
fn import() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_import::<AS, S>, state);
let mut service = Handler::with_state(route_put::<AS, S>, state);
Box::pin(Service::call(&mut service, req))
})
}

View File

@@ -9,7 +9,7 @@ use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::{
AddressbookStore, SubscriptionStore,
auth::{AuthenticationProvider, Principal},
auth::{AuthenticationProvider, User},
};
use std::sync::Arc;
@@ -22,11 +22,8 @@ pub mod principal;
pub struct CardDavPrincipalUri(&'static str);
impl PrincipalUri for CardDavPrincipalUri {
fn principal_collection(&self) -> String {
format!("{}/principal/", self.0)
}
fn principal_uri(&self, principal: &str) -> String {
format!("{}{}/", self.principal_collection(), principal)
format!("{}/principal/{}/", self.0, principal)
}
}
@@ -44,12 +41,10 @@ pub fn carddav_router<AP: AuthenticationProvider, A: AddressbookStore, S: Subscr
Router::new()
.nest(
prefix,
RootResourceService::<_, Principal, CardDavPrincipalUri>::new(
principal_service.clone(),
)
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix))),
RootResourceService::<_, User, CardDavPrincipalUri>::new(principal_service.clone())
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix))),
)
.route(
"/.well-known/carddav",

View File

@@ -2,10 +2,8 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner,
};
use rustical_store::auth::Principal;
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_store::auth::User;
mod service;
pub use service::*;
@@ -14,8 +12,7 @@ pub use prop::*;
#[derive(Debug, Clone)]
pub struct PrincipalResource {
principal: Principal,
members: Vec<String>,
principal: User,
}
impl ResourceName for PrincipalResource {
@@ -27,11 +24,9 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = Principal;
type Principal = User;
fn is_collection(&self) -> bool {
true
}
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -43,46 +38,33 @@ impl Resource for PrincipalResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &Principal,
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id));
let principal_href = HrefElement::new(puri.principal_uri(&user.id));
let home_set = AddressbookHomeSet(
user.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal))
.map(HrefElement::new)
.collect(),
);
Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop {
PrincipalPropName::Displayname => PrincipalProp::Displayname(
self.principal
.displayname
.to_owned()
.unwrap_or(self.principal.id.to_owned()),
),
PrincipalPropName::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(AddressbookHomeSet(
self.principal
.memberships()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
PrincipalProp::AddressbookHomeSet(home_set)
}
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => {
PrincipalProp::GroupMembership(GroupMembership(
self.principal
.memberships_without_self()
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::GroupMemberSet => {
PrincipalProp::GroupMemberSet(GroupMemberSet(
self.members
.iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
))
}
PrincipalPropName::AlternateUriSet => PrincipalProp::AlternateUriSet,
PrincipalPropName::PrincipalCollectionSet => {
PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
}
})
}
@@ -92,20 +74,11 @@ impl Resource for PrincipalResource {
})
}
fn get_displayname(&self) -> Option<&str> {
Some(
self.principal
.displayname
.as_ref()
.unwrap_or(&self.principal.id),
)
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal.id)
}
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id),
))

View File

@@ -1,24 +1,19 @@
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement},
};
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")]
pub enum PrincipalProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
Displayname(String),
// WebDAV Access Control (RFC 3744)
#[xml(rename = "principal-URL")]
#[xml(rename = b"principal-URL")]
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalUrl(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMembership(GroupMembership),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
GroupMemberSet(GroupMemberSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", rename = "alternate-URI-set")]
AlternateUriSet,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalCollectionSet(HrefElement),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
@@ -27,9 +22,6 @@ pub enum PrincipalProp {
PrincipalAddress(Option<HrefElement>),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropWrapperName", untagged)]
pub enum PrincipalPropWrapper {

View File

@@ -5,7 +5,7 @@ use crate::{CardDavPrincipalUri, Error};
use async_trait::async_trait;
use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::sync::Arc;
@@ -51,7 +51,7 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
type MemberType = AddressbookResource;
type Resource = PrincipalResource;
type Error = Error;
type Principal = Principal;
type Principal = User;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -59,17 +59,13 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
async fn get_resource(
&self,
(principal,): &Self::PathComponents,
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
let user = self
.auth_provider
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
Ok(PrincipalResource { principal: user })
}
async fn get_members(

View File

@@ -4,7 +4,6 @@ version.workspace = true
edition.workspace = true
description.workspace = true
repository.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -25,6 +24,3 @@ tracing.workspace = true
tokio.workspace = true
http.workspace = true
headers.workspace = true
strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true

View File

@@ -1,4 +1,3 @@
use axum::body::Body;
use http::StatusCode;
use rustical_xml::XmlError;
use thiserror::Error;
@@ -29,9 +28,6 @@ pub enum Error {
#[error("Precondition Failed")]
PreconditionFailed,
#[error("Forbidden")]
Forbidden,
}
impl Error {
@@ -53,19 +49,13 @@ impl Error {
Error::PropReadOnly => StatusCode::CONFLICT,
Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN,
}
}
}
impl axum::response::IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
if matches!(
self.status_code(),
StatusCode::INTERNAL_SERVER_ERROR | StatusCode::PRECONDITION_FAILED
) {
error!("{self}");
}
use axum::body::Body;
let mut resp = axum::response::Response::builder().status(self.status_code());
if matches!(&self, &Error::Unauthorized) {

View File

@@ -13,8 +13,6 @@ pub enum CommonPropertiesProp {
#[xml(skip_deserializing)]
#[xml(ns = "crate::namespace::NS_DAV")]
Resourcetype(Resourcetype),
#[xml(ns = "crate::namespace::NS_DAV")]
Displayname(Option<String>),
// WebDAV Current Principal Extension (RFC 5397)
#[xml(ns = "crate::namespace::NS_DAV")]
@@ -39,9 +37,6 @@ pub trait CommonPropertiesExtension: Resource {
CommonPropertiesPropName::Resourcetype => {
CommonPropertiesProp::Resourcetype(self.get_resourcetype())
}
CommonPropertiesPropName::Displayname => {
CommonPropertiesProp::Displayname(self.get_displayname().map(|s| s.to_string()))
}
CommonPropertiesPropName::CurrentUserPrincipal => {
CommonPropertiesProp::CurrentUserPrincipal(
principal_uri.principal_uri(principal.get_id()).into(),
@@ -57,18 +52,12 @@ pub trait CommonPropertiesExtension: Resource {
})
}
fn set_prop(&mut self, prop: CommonPropertiesProp) -> Result<(), crate::Error> {
match prop {
CommonPropertiesProp::Displayname(name) => self.set_displayname(name),
_ => Err(crate::Error::PropReadOnly),
}
fn set_prop(&self, _prop: CommonPropertiesProp) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
}
fn remove_prop(&mut self, prop: &CommonPropertiesPropName) -> Result<(), crate::Error> {
match prop {
CommonPropertiesPropName::Displayname => self.set_displayname(None),
_ => Err(crate::Error::PropReadOnly),
}
fn remove_prop(&self, _prop: &CommonPropertiesPropName) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
}
}

View File

@@ -14,12 +14,16 @@ impl IntoResponse for InvalidOverwriteHeader {
}
}
#[derive(Debug, PartialEq)]
pub struct Overwrite(pub bool);
#[derive(Debug, PartialEq, Default)]
pub enum Overwrite {
#[default]
T,
F,
}
impl Default for Overwrite {
fn default() -> Self {
Self(true)
impl Overwrite {
pub fn is_true(&self) -> bool {
matches!(self, Self::T)
}
}
@@ -43,48 +47,9 @@ impl TryFrom<&[u8]> for Overwrite {
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
match value {
b"T" => Ok(Self(true)),
b"F" => Ok(Self(false)),
b"T" => Ok(Overwrite::T),
b"F" => Ok(Overwrite::F),
_ => Err(InvalidOverwriteHeader),
}
}
}
#[cfg(test)]
mod tests {
use axum::{extract::FromRequestParts, response::IntoResponse};
use http::Request;
use crate::header::Overwrite;
#[tokio::test]
async fn test_overwrite_default() {
let request = Request::put("asd").body(()).unwrap();
let (mut parts, _) = request.into_parts();
let overwrite = Overwrite::from_request_parts(&mut parts, &())
.await
.unwrap();
assert_eq!(
Overwrite(true),
overwrite,
"By default we want to overwrite!"
);
}
#[test]
fn test_overwrite() {
assert_eq!(
Overwrite(true),
Overwrite::try_from(b"T".as_slice()).unwrap()
);
assert_eq!(
Overwrite(false),
Overwrite::try_from(b"F".as_slice()).unwrap()
);
if let Err(err) = Overwrite::try_from(b"aslkdjlad".as_slice()) {
let _ = err.into_response();
} else {
unreachable!("should return error")
}
}
}

View File

@@ -1,10 +1,8 @@
use itertools::Itertools;
use quick_xml::name::Namespace;
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::collections::{HashMap, HashSet};
// https://datatracker.ietf.org/doc/html/rfc3744
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq, PartialOrd, Ord)]
#[derive(Debug, Clone, XmlSerialize, XmlDeserialize, Eq, Hash, PartialEq)]
pub enum UserPrivilege {
Read,
Write,
@@ -17,25 +15,26 @@ pub enum UserPrivilege {
}
impl XmlSerialize for UserPrivilegeSet {
fn serialize(
fn serialize<W: std::io::Write>(
&self,
ns: Option<Namespace>,
tag: Option<&str>,
namespaces: &HashMap<Namespace, &str>,
writer: &mut quick_xml::Writer<&mut Vec<u8>>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> {
#[derive(XmlSerialize)]
pub struct FakeUserPrivilegeSet {
#[xml(rename = "privilege", flatten)]
#[xml(rename = b"privilege", flatten)]
privileges: Vec<UserPrivilege>,
}
FakeUserPrivilegeSet {
privileges: self.privileges.iter().cloned().sorted().collect(),
privileges: self.privileges.iter().cloned().collect(),
}
.serialize(ns, tag, namespaces, writer)
}
#[allow(refining_impl_trait)]
fn attributes<'a>(&self) -> Option<Vec<quick_xml::events::attributes::Attribute<'a>>> {
None
}
@@ -48,12 +47,6 @@ pub struct UserPrivilegeSet {
impl UserPrivilegeSet {
pub fn has(&self, privilege: &UserPrivilege) -> bool {
if (privilege == &UserPrivilege::WriteProperties
|| privilege == &UserPrivilege::WriteContent)
&& self.privileges.contains(&UserPrivilege::Write)
{
return true;
}
self.privileges.contains(privilege) || self.privileges.contains(&UserPrivilege::All)
}
@@ -79,15 +72,6 @@ impl UserPrivilegeSet {
}
}
pub fn owner_write_properties(is_owner: bool) -> Self {
// Content is read-only but we can write properties
if is_owner {
Self::write_properties()
} else {
Self::default()
}
}
pub fn read_only() -> Self {
Self {
privileges: HashSet::from([
@@ -97,17 +81,6 @@ impl UserPrivilegeSet {
]),
}
}
pub fn write_properties() -> Self {
Self {
privileges: HashSet::from([
UserPrivilege::Read,
UserPrivilege::WriteProperties,
UserPrivilege::ReadAcl,
UserPrivilege::ReadCurrentUserPrivilegeSet,
]),
}
}
}
impl<const N: usize> From<[UserPrivilege; N]> for UserPrivilegeSet {

View File

@@ -18,6 +18,11 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None
}
#[inline]
fn head() -> Option<MethodFunction<Self>> {
None
}
#[inline]
fn post() -> Option<MethodFunction<Self>> {
None
@@ -38,11 +43,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
None
}
#[inline]
fn import() -> Option<MethodFunction<Self>> {
None
}
#[inline]
fn allow_header() -> Allow {
let mut allow = vec![
@@ -58,6 +58,8 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
}
if Self::get().is_some() {
allow.push(Method::GET);
}
if Self::head().is_some() {
allow.push(Method::HEAD);
}
if Self::post().is_some() {
@@ -72,9 +74,6 @@ pub trait AxumMethods: Sized + Send + Sync + 'static {
if Self::put().is_some() {
allow.push(Method::PUT);
}
if Self::import().is_some() {
allow.push(Method::from_str("IMPORT").unwrap());
}
allow.into_iter().collect()
}

View File

@@ -72,11 +72,16 @@ where
return svc(self.resource_service.clone(), req);
}
}
"GET" | "HEAD" => {
"GET" => {
if let Some(svc) = RS::get() {
return svc(self.resource_service.clone(), req);
}
}
"HEAD" => {
if let Some(svc) = RS::head() {
return svc(self.resource_service.clone(), req);
}
}
"POST" => {
if let Some(svc) = RS::post() {
return svc(self.resource_service.clone(), req);
@@ -97,11 +102,6 @@ where
return svc(self.resource_service.clone(), req);
}
}
"IMPORT" => {
if let Some(svc) = RS::import() {
return svc(self.resource_service.clone(), req);
}
}
_ => {}
};
Box::pin(async move {
@@ -114,9 +114,6 @@ where
}
async fn route_options<RS: ResourceService + AxumMethods>() -> Response<Body> {
// Semantically NO_CONTENT would also make sense,
// but GNOME Accounts only works when returning OK
// https://gitlab.gnome.org/GNOME/gnome-online-accounts/-/blob/master/src/goabackend/goadavclient.c#L289
let mut resp = Response::builder().status(StatusCode::OK);
let headers = resp.headers_mut().unwrap();
headers.insert("DAV", HeaderValue::from_static(RS::DAV_HEADER));

View File

@@ -1,54 +1,25 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{
header::{Depth, Overwrite},
resource::ResourceService,
};
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(path, resource_service,))]
#[instrument(skip(_path, _resource_service,))]
pub(crate) async fn axum_route_copy<R: ResourceService>(
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
Path(_path): Path<R::PathComponents>,
State(_resource_service): State<R>,
depth: Option<Depth>,
principal: R::Principal,
Overwrite(overwrite): Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
overwrite: Overwrite,
) -> Result<Response, R::Error> {
let destination = header_map
.get("Destination")
.ok_or(crate::Error::Forbidden)?
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite)
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but
// can return a 403 error
let _depth = depth.unwrap_or(Depth::Infinity);
Ok(StatusCode::FORBIDDEN.into_response())
}

View File

@@ -45,11 +45,10 @@ pub async fn route_delete<R: ResourceService>(
if_match: Option<IfMatch>,
if_none_match: Option<IfNoneMatch>,
) -> Result<(), R::Error> {
let resource = resource_service.get_resource(path_components, true).await?;
let resource = resource_service.get_resource(path_components).await?;
// Kind of a bodge since we don't get unbind from the parent
let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::WriteProperties) {
if !privileges.has(&UserPrivilege::Write) {
return Err(Error::Unauthorized.into());
}
@@ -60,11 +59,11 @@ pub async fn route_delete<R: ResourceService>(
return Err(crate::Error::PreconditionFailed.into());
}
}
if let Some(if_none_match) = if_none_match
&& resource.satisfies_if_none_match(&if_none_match)
{
// Precondition failed
return Err(crate::Error::PreconditionFailed.into());
if let Some(if_none_match) = if_none_match {
if resource.satisfies_if_none_match(&if_none_match) {
// Precondition failed
return Err(crate::Error::PreconditionFailed.into());
}
}
resource_service
.delete_resource(path_components, !no_trash)

View File

@@ -1,54 +1,25 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Response},
};
use http::StatusCode;
use tracing::instrument;
use crate::{
header::{Depth, Overwrite},
resource::ResourceService,
};
use axum::{
extract::{MatchedPath, Path, State},
response::{IntoResponse, Response},
};
use http::{HeaderMap, StatusCode, Uri};
use matchit_serde::ParamsDeserializer;
use serde::Deserialize;
use tracing::instrument;
#[instrument(skip(path, resource_service,))]
#[instrument(skip(_path, _resource_service,))]
pub(crate) async fn axum_route_move<R: ResourceService>(
Path(path): Path<R::PathComponents>,
State(resource_service): State<R>,
Path(_path): Path<R::PathComponents>,
State(_resource_service): State<R>,
depth: Option<Depth>,
principal: R::Principal,
Overwrite(overwrite): Overwrite,
matched_path: MatchedPath,
header_map: HeaderMap,
overwrite: Overwrite,
) -> Result<Response, R::Error> {
let destination = header_map
.get("Destination")
.ok_or(crate::Error::Forbidden)?
.to_str()
.map_err(|_| crate::Error::Forbidden)?;
let destination_uri: Uri = destination.parse().map_err(|_| crate::Error::Forbidden)?;
// TODO: Check that host also matches
let destination = destination_uri.path();
let mut router = matchit::Router::new();
router.insert(matched_path.as_str(), ()).unwrap();
if let Ok(matchit::Match { params, .. }) = router.at(destination) {
let params =
matchit_serde::Params::try_from(&params).map_err(|_| crate::Error::Forbidden)?;
let dest_path = R::PathComponents::deserialize(&ParamsDeserializer::new(params))
.map_err(|_| crate::Error::Forbidden)?;
if resource_service
.copy_resource(&path, &dest_path, &principal, overwrite)
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
// TODO: Actually implement, but to be WebDAV-compliant we must at least support this route but
// can return a 403 error
let _depth = depth.unwrap_or(Depth::Infinity);
Ok(StatusCode::FORBIDDEN.into_response())
}

View File

@@ -6,7 +6,11 @@ use crate::resource::Resource;
use crate::resource::ResourceName;
use crate::resource::ResourceService;
use crate::xml::MultistatusElement;
use crate::xml::PropfindElement;
use crate::xml::PropfindType;
use axum::extract::{Extension, OriginalUri, Path, State};
use rustical_xml::PropName;
use rustical_xml::XmlDocument;
use tracing::instrument;
type RSMultistatus<R> = MultistatusElement<
@@ -45,39 +49,43 @@ pub(crate) async fn route_propfind<R: ResourceService>(
resource_service: &R,
puri: &impl PrincipalUri,
) -> Result<RSMultistatus<R>, R::Error> {
let resource = resource_service
.get_resource(path_components, false)
.await?;
let resource = resource_service.get_resource(path_components).await?;
let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Read) {
return Err(Error::Unauthorized.into());
}
// A request body is optional. If empty we MUST return all props
let propfind_self = R::Resource::parse_propfind(body).map_err(Error::XmlError)?;
let propfind_member = R::MemberType::parse_propfind(body).map_err(Error::XmlError)?;
let propfind_self: PropfindElement<<<R::Resource as Resource>::Prop as PropName>::Names> =
if !body.is_empty() {
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
}
};
let propfind_member: PropfindElement<<<R::MemberType as Resource>::Prop as PropName>::Names> =
if !body.is_empty() {
PropfindElement::parse_str(body).map_err(Error::XmlError)?
} else {
PropfindElement {
prop: PropfindType::Allprop,
}
};
let mut member_responses = Vec::new();
if depth != &Depth::Zero {
// TODO: authorization check for member resources
for member in resource_service.get_members(path_components).await? {
member_responses.push(member.propfind(
&format!("{}/{}", path.trim_end_matches('/'), member.get_name()),
&propfind_member.prop,
propfind_member.include.as_ref(),
puri,
principal,
)?);
}
}
let response = resource.propfind(
path,
&propfind_self.prop,
propfind_self.include.as_ref(),
puri,
principal,
)?;
let response = resource.propfind(path, &propfind_self.prop, puri, principal)?;
Ok(MultistatusElement {
responses: vec![response],

View File

@@ -26,21 +26,21 @@ enum SetPropertyPropWrapper<T: XmlDeserialize> {
// We are <prop>
#[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyPropWrapperWrapper<T: XmlDeserialize>(
#[xml(ty = "untagged", flatten)] Vec<SetPropertyPropWrapper<T>>,
#[xml(ty = "untagged")] SetPropertyPropWrapper<T>,
);
// We are <set>
#[derive(XmlDeserialize, Clone, Debug)]
struct SetPropertyElement<T: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
prop: SetPropertyPropWrapperWrapper<T>,
prop: T,
}
#[derive(XmlDeserialize, Clone, Debug)]
struct TagName(#[xml(ty = "tag_name")] String);
#[derive(XmlDeserialize, Clone, Debug)]
struct PropertyElement(#[xml(ty = "untagged", flatten)] Vec<TagName>);
struct PropertyElement(#[xml(ty = "untagged")] TagName);
#[derive(XmlDeserialize, Clone, Debug)]
struct RemovePropertyElement {
@@ -57,7 +57,7 @@ enum Operation<T: XmlDeserialize> {
}
#[derive(XmlDeserialize, XmlRootTag, Clone, Debug)]
#[xml(root = "propertyupdate")]
#[xml(root = b"propertyupdate")]
#[xml(ns = "crate::namespace::NS_DAV")]
struct PropertyupdateElement<T: XmlDeserialize>(#[xml(ty = "untagged", flatten)] Vec<Operation<T>>);
@@ -81,12 +81,11 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
let href = path.to_owned();
// Extract operations
let PropertyupdateElement::<<R::Resource as Resource>::Prop>(operations) =
XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let PropertyupdateElement::<SetPropertyPropWrapperWrapper<<R::Resource as Resource>::Prop>>(
operations,
) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service
.get_resource(path_components, false)
.await?;
let mut resource = resource_service.get_resource(path_components).await?;
let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Write) {
return Err(Error::Unauthorized.into());
@@ -99,63 +98,59 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
for operation in operations.into_iter() {
match operation {
Operation::Set(SetPropertyElement {
prop: SetPropertyPropWrapperWrapper(properties),
prop: SetPropertyPropWrapperWrapper(property),
}) => {
for property in properties {
match property {
SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) {
Ok(()) => props_ok
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()),
};
}
SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name();
if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter()
.find_map(|(ns, tag)| {
if tag == propname.as_str() {
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else {
None
}
})
{
// This happens in following cases:
// - read-only properties with #[serde(skip_deserializing)]
// - internal properties
props_conflict.push(full_propname)
} else {
props_not_found.push((None, propname));
match property {
SetPropertyPropWrapper::Valid(prop) => {
let propname: <<R::Resource as Resource>::Prop as PropName>::Names =
prop.clone().into();
let (ns, propname): (Option<Namespace>, &str) = propname.into();
match resource.set_prop(prop) {
Ok(()) => {
props_ok.push((ns.map(NamespaceOwned::from), propname.to_owned()))
}
Err(Error::PropReadOnly) => props_conflict
.push((ns.map(NamespaceOwned::from), propname.to_owned())),
Err(err) => return Err(err.into()),
};
}
SetPropertyPropWrapper::Invalid(invalid) => {
let propname = invalid.tag_name();
if let Some(full_propname) = <R::Resource as Resource>::list_props()
.into_iter()
.find_map(|(ns, tag)| {
if tag == propname.as_str() {
Some((ns.map(NamespaceOwned::from), tag.to_owned()))
} else {
None
}
})
{
// This happens in following cases:
// - read-only properties with #[serde(skip_deserializing)]
// - internal properties
props_conflict.push(full_propname)
} else {
props_not_found.push((None, propname));
}
}
}
}
Operation::Remove(remove_el) => {
for tagname in remove_el.prop.0 {
let propname = tagname.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname)
{
Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({
let (ns, tag) = prop.into();
(ns.map(NamespaceOwned::from), tag.to_owned())
}),
Err(err) => return Err(err.into()),
},
// I guess removing a nonexisting property should be successful :)
Err(_) => props_ok.push((None, propname)),
};
}
let propname = remove_el.prop.0.0;
match <<R::Resource as Resource>::Prop as PropName>::Names::from_str(&propname) {
Ok(prop) => match resource.remove_prop(&prop) {
Ok(()) => props_ok.push((None, propname)),
Err(Error::PropReadOnly) => props_conflict.push({
let (ns, tag) = prop.into();
(ns.map(NamespaceOwned::from), tag.to_owned())
}),
Err(err) => return Err(err.into()),
},
// I guess removing a nonexisting property should be successful :)
Err(_) => props_ok.push((None, propname)),
};
}
}
}

View File

@@ -1,16 +1,15 @@
use crate::Principal;
use crate::privileges::UserPrivilegeSet;
use crate::xml::multistatus::{PropTagWrapper, PropstatElement, PropstatWrapper};
use crate::xml::{PropElement, PropfindElement, PropfindType, Resourcetype};
use crate::xml::{PropElement, PropfindType, Resourcetype};
use crate::xml::{TagList, multistatus::ResponseElement};
use headers::{ETag, IfMatch, IfNoneMatch};
use http::StatusCode;
use itertools::Itertools;
use quick_xml::name::Namespace;
pub use resource_service::ResourceService;
use rustical_xml::{
EnumVariants, NamespaceOwned, PropName, XmlDeserialize, XmlDocument, XmlSerialize,
};
use rustical_xml::{EnumVariants, NamespaceOwned, PropName, XmlDeserialize, XmlSerialize};
use std::collections::HashSet;
use std::str::FromStr;
mod axum_methods;
@@ -19,7 +18,7 @@ mod methods;
mod principal_uri;
mod resource_service;
pub use axum_methods::{AxumMethods, MethodFunction};
pub use axum_methods::AxumMethods;
pub use axum_service::AxumService;
pub use principal_uri::PrincipalUri;
@@ -38,7 +37,7 @@ pub trait Resource: Clone + Send + 'static {
type Error: From<crate::Error>;
type Principal: Principal;
fn is_collection(&self) -> bool;
const IS_COLLECTION: bool;
fn get_resourcetype(&self) -> Resourcetype;
@@ -61,11 +60,6 @@ pub trait Resource: Clone + Send + 'static {
Err(crate::Error::PropReadOnly)
}
fn get_displayname(&self) -> Option<&str>;
fn set_displayname(&mut self, _name: Option<String>) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
}
fn get_owner(&self) -> Option<&str> {
None
}
@@ -103,67 +97,49 @@ pub trait Resource: Clone + Send + 'static {
principal: &Self::Principal,
) -> Result<UserPrivilegeSet, Self::Error>;
fn parse_propfind(
body: &str,
) -> Result<PropfindElement<<Self::Prop as PropName>::Names>, rustical_xml::XmlError> {
if !body.is_empty() {
PropfindElement::parse_str(body)
} else {
Ok(PropfindElement {
prop: PropfindType::Allprop,
include: None,
})
}
}
fn propfind(
&self,
path: &str,
prop: &PropfindType<<Self::Prop as PropName>::Names>,
include: Option<&PropElement<<Self::Prop as PropName>::Names>>,
principal_uri: &impl PrincipalUri,
principal: &Self::Principal,
) -> Result<ResponseElement<Self::Prop>, Self::Error> {
// Collections have a trailing slash
let mut path = path.to_string();
if self.is_collection() && !path.ends_with('/') {
if Self::IS_COLLECTION && !path.ends_with('/') {
path.push('/');
}
let (mut props, mut invalid_props): (Vec<<Self::Prop as PropName>::Names>, Vec<_>) =
match prop {
PropfindType::Propname => {
let props = Self::list_props()
.into_iter()
.map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string()))
.collect_vec();
// TODO: Support include element
let (props, invalid_props): (HashSet<<Self::Prop as PropName>::Names>, Vec<_>) = match prop
{
PropfindType::Propname => {
let props = Self::list_props()
.into_iter()
.map(|(ns, tag)| (ns.map(NamespaceOwned::from), tag.to_string()))
.collect_vec();
return Ok(ResponseElement {
href: path.to_owned(),
propstat: vec![PropstatWrapper::TagList(PropstatElement {
prop: TagList::from(props),
status: StatusCode::OK,
})],
..Default::default()
});
}
PropfindType::Allprop => (
Self::list_props()
.iter()
.map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap())
.collect(),
vec![],
),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().unique().cloned().collect(),
invalid_tags.to_owned(),
),
};
if let Some(PropElement(valid_tags, invalid_tags)) = include {
props.extend(valid_tags.clone());
invalid_props.extend(invalid_tags.to_owned());
}
return Ok(ResponseElement {
href: path.to_owned(),
propstat: vec![PropstatWrapper::TagList(PropstatElement {
prop: TagList::from(props),
status: StatusCode::OK,
})],
..Default::default()
});
}
PropfindType::Allprop => (
Self::list_props()
.iter()
.map(|(_ns, name)| <Self::Prop as PropName>::Names::from_str(name).unwrap())
.collect(),
vec![],
),
PropfindType::Prop(PropElement(valid_tags, invalid_tags)) => (
valid_tags.iter().cloned().collect(),
invalid_tags.to_owned(),
),
};
let prop_responses = props
.into_iter()

View File

@@ -1,4 +1,3 @@
pub trait PrincipalUri: 'static + Clone + Send + Sync {
fn principal_collection(&self) -> String;
fn principal_uri(&self, principal: &str) -> String;
}

View File

@@ -9,13 +9,7 @@ use serde::Deserialize;
#[async_trait]
pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
type PathComponents: std::fmt::Debug
+ for<'de> Deserialize<'de>
+ Sized
+ Send
+ Sync
+ Clone
+ 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type PathComponents: for<'de> Deserialize<'de> + Sized + Send + Sync + Clone + 'static; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type MemberType: Resource<Error = Self::Error, Principal = Self::Principal>
+ super::ResourceName;
type Resource: Resource<Error = Self::Error, Principal = Self::Principal>;
@@ -34,8 +28,7 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
async fn get_resource(
&self,
path: &Self::PathComponents,
show_deleted: bool,
_path: &Self::PathComponents,
) -> Result<Self::Resource, Self::Error>;
async fn save_resource(
@@ -54,28 +47,6 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
Err(crate::Error::Unauthorized.into())
}
// Returns whether an existing resource was overwritten
async fn copy_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
// Returns whether an existing resource was overwritten
async fn move_resource(
&self,
_path: &Self::PathComponents,
_destination: &Self::PathComponents,
_user: &Self::Principal,
_overwrite: bool,
) -> Result<bool, Self::Error> {
Err(crate::Error::Forbidden.into())
}
fn axum_service(self) -> AxumService<Self>
where
Self: AxumMethods,

View File

@@ -1,72 +1,3 @@
pub mod root;
pub use root::{RootResource, RootResourceService};
#[cfg(test)]
pub mod test {
use crate::{
Error, Principal,
extensions::{CommonPropertiesExtension, CommonPropertiesProp},
namespace::NS_DAV,
privileges::UserPrivilegeSet,
resource::{PrincipalUri, Resource},
xml::{Resourcetype, ResourcetypeInner},
};
#[derive(Debug, Clone)]
pub struct TestPrincipal(pub String);
impl Principal for TestPrincipal {
fn get_id(&self) -> &str {
&self.0
}
}
impl Resource for TestPrincipal {
type Prop = CommonPropertiesProp;
type Error = Error;
type Principal = Self;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> crate::xml::Resourcetype {
Resourcetype(&[ResourcetypeInner(Some(NS_DAV), "collection")])
}
fn get_prop(
&self,
principal_uri: &impl crate::resource::PrincipalUri,
principal: &Self::Principal,
prop: &<Self::Prop as rustical_xml::PropName>::Names,
) -> Result<Self::Prop, Self::Error> {
<Self as CommonPropertiesExtension>::get_prop(self, principal_uri, principal, prop)
}
fn get_displayname(&self) -> Option<&str> {
Some(&self.0)
}
fn get_user_privileges(
&self,
principal: &Self::Principal,
) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
principal.get_id() == self.get_id(),
))
}
}
#[derive(Debug, Clone)]
pub struct TestPrincipalUri;
impl PrincipalUri for TestPrincipalUri {
fn principal_collection(&self) -> String {
"/".to_owned()
}
fn principal_uri(&self, principal: &str) -> String {
format!("/{principal}/")
}
}
}

View File

@@ -24,9 +24,7 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
type Error = PR::Error;
type Principal = P;
fn is_collection(&self) -> bool {
true
}
const IS_COLLECTION: bool = true;
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner(
@@ -35,10 +33,6 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
)])
}
fn get_displayname(&self) -> Option<&str> {
Some("RustiCal DAV root")
}
fn get_prop(
&self,
principal_uri: &impl PrincipalUri,
@@ -86,11 +80,7 @@ where
const DAV_HEADER: &str = "1, 3, access-control";
async fn get_resource(
&self,
_: &(),
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
async fn get_resource(&self, _: &()) -> Result<Self::Resource, Self::Error> {
Ok(RootResource::<PRS::Resource, P>::default())
}
@@ -105,33 +95,3 @@ impl<PRS: ResourceService<Principal = P> + Clone, P: Principal, PURI: PrincipalU
for RootResourceService<PRS, P, PURI>
{
}
#[cfg(test)]
mod test {
use crate::{
resource::Resource,
resources::{
RootResource,
test::{TestPrincipal, TestPrincipalUri},
},
};
#[test]
fn test_root_resource() {
let resource = RootResource::<TestPrincipal, TestPrincipal>::default();
let propfind = RootResource::<TestPrincipal, TestPrincipal>::parse_propfind(
r#"<?xml version="1.0" encoding="UTF-8"?><propfind xmlns="DAV:"><allprop/></propfind>"#,
)
.unwrap();
let _response = resource
.propfind(
"/",
&propfind.prop,
propfind.include.as_ref(),
&TestPrincipalUri,
&TestPrincipal("user".to_owned()),
)
.unwrap();
}
}

View File

@@ -1,12 +1,12 @@
use rustical_xml::{XmlRootTag, XmlSerialize};
#[derive(XmlSerialize, XmlRootTag)]
#[xml(ns = "crate::namespace::NS_DAV", root = "error")]
#[xml(ns = "crate::namespace::NS_DAV", root = b"error")]
#[xml(ns_prefix(
crate::namespace::NS_DAV = "",
crate::namespace::NS_CARDDAV = "CARD",
crate::namespace::NS_CALDAV = "CAL",
crate::namespace::NS_CALENDARSERVER = "CS",
crate::namespace::NS_DAVPUSH = "PUSH"
crate::namespace::NS_DAV = b"",
crate::namespace::NS_CARDDAV = b"CARD",
crate::namespace::NS_CALDAV = b"CAL",
crate::namespace::NS_CALENDARSERVER = b"CS",
crate::namespace::NS_DAVPUSH = b"PUSH"
))]
pub struct ErrorElement<'t, T: XmlSerialize>(#[xml(ty = "untagged")] pub &'t T);

View File

@@ -1,8 +0,0 @@
use crate::xml::HrefElement;
use rustical_xml::{XmlDeserialize, XmlSerialize};
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMemberSet(#[xml(ty = "untagged", flatten)] pub Vec<HrefElement>);

Some files were not shown because too many files have changed in this diff Show More