Compare commits

...

18 Commits

Author SHA1 Message Date
Lennart
b6ef2b4c05 Update documentation given the changes to memberships 2025-06-12 20:59:50 +02:00
Lennart
32bc8c707d Add group-membership to both caldav and carddav and fix addressbook-home-set for shared principals 2025-06-12 20:55:22 +02:00
Lennart
1757bbee13 carddav: Remove members from addressbook-home-set 2025-06-12 20:12:17 +02:00
Lennart
4dbc316e64 Remove member principals from calendar-home-set 2025-06-12 20:10:14 +02:00
Lennart
4705170dbc Update .sqlx files 2025-06-12 20:05:51 +02:00
Lennart
0e2f08d7f2 caldav: Add some access control-related properties and advertise calendar-proxy 2025-06-12 19:51:02 +02:00
Lennart
feb8b3ff09 Add member search to user store 2025-06-12 19:50:32 +02:00
Lennart
41d5c72e4e Fix and simplify support-report-set 2025-06-12 17:39:42 +02:00
Lennart
89adbcf13f xml: Fix default namespace prefixing for enum variants 2025-06-12 17:38:56 +02:00
Lennart
5a3a2c0909 Fix TagList not writing the <prop> wrapper 2025-06-12 16:18:33 +02:00
Lennart
3e8fffa316 Fix xml PropName such that the rename attribute also propagates to the prop name 2025-06-12 16:07:32 +02:00
Lennart
40e7bc0f66 Fix tests 2025-06-12 15:33:49 +02:00
Lennart
f857d68760 principal: Implement principal-collection-set 2025-06-12 15:31:34 +02:00
Lennart
9e5eaa5e1c Fix bug where principal collections would return information about the requesting user instead of the principal resource 2025-06-12 15:23:02 +02:00
Lennart
7c73223877 dav: Implement some principal props for WebDAV ACL 2025-06-12 15:00:54 +02:00
Lennart K
0c1c04d1cd dav: Move displayname to common properties 2025-06-12 14:39:16 +02:00
Lennart
72961f44e0 Update Docker workflow to hopefully tag releases 2025-06-11 22:09:29 +02:00
Lennart
49ac6abf35 Update .gitattributes 2025-06-11 21:45:06 +02:00
89 changed files with 367 additions and 1476 deletions

2
.gitattributes vendored Normal file
View File

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

View File

@@ -3,6 +3,9 @@ name: Docker
on:
push:
branches: ["main"]
release:
types: ["published"]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
@@ -42,7 +45,6 @@ jobs:
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

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM app_tokens WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "0195268daddd2d171577c93d1bae1b8937405bcefffa8f1f9b9c9f7f2084088f"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET deleted_at = NULL WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "02a9260d0ff496a6bf226fc8238ae332f8eb18dddbd80d31989c074804f31dee"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO app_tokens\n (id, principal, token, displayname)\n VALUES (?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "0fd3167a58cbfb4ee44249dbc346d2d9077adfa04c35c8c6f2a1e24720baf753"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendarobjects SET deleted_at = NULL, updated_at = datetime() WHERE (principal, cal_id, id) = (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "10325688a6601f6205cde9d9e2d582ca87a46607a1d889af155debc3073d78e1"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, synctoken, displayname, description, deleted_at, push_topic\n FROM addressbooks\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?) ",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "push_topic",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "130986d03d4d78ceeb15aff6f4d6304f0be0100e4bffad9cc3f6c1a2c6c4b297"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE addressbooks SET principal = ?, id = ?, displayname = ?, description = ?, push_topic = ?\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "16a7e0cb4527060339c168ee2528416036e401f75a03100b6bfbee687b978520"
}

View File

@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, displayname AS name, token, created_at AS \"created_at: _\" FROM app_tokens WHERE principal = ?",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "token",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "created_at: _",
"ordinal": 3,
"type_info": "Datetime"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "1ebaf3fd99bee2382abc931a1eeb29badc3aabcf6b8fd58e4cf92721588a9966"
}

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS \"memberships: Json<Vec<Option<String>>>\"\n FROM principals\n LEFT JOIN memberships ON principals.id == memberships.principal\n GROUP BY principals.id\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "displayname",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "principal_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "memberships: Json<Vec<Option<String>>>",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
false,
true,
true
]
},
"hash": "23a07f4a732f95ff7483cd1cfe3b74af4fe6b97546a631bc96d03bdc3d764ed0"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM calendars WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "2834e16e6a7acb58141a2433f7735d5e2bf913c30f9f3e7bd9fecc7d4376be0f"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE addressobjects SET deleted_at = NULL, updated_at = datetime() WHERE (principal, addressbook_id, id) = (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "2b145d094188fab69371d98520a034c69c0b61583d4e245388d3879d290619d0"
}

View File

@@ -1,12 +0,0 @@
{
"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,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM addressbooks WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "3885219f7e132876fa8bdfd31c0761d54fbcfe2846ffd8e4e94feb40547205be"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, vcf FROM addressobjects WHERE (principal, addressbook_id, id) = (?, ?, ?) AND ((deleted_at IS NULL) or ?)",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "vcf",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 4
},
"nullable": [
false,
false
]
},
"hash": "395e40a7b3333b79bc2ad50a123d99f74bc2712a16257ee2119dd211fdb61f7e"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM principals WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3a1dbfbe9d22a62f1830d004548b7e805bcb9fdd24b49c8c9efa93df149b1002"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO calendarobjects (principal, cal_id, id, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "3e1cca532372e891ab3e604ecb79311d8cd64108d4f238db4c79e9467a3b6d2e"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT DISTINCT object_id, max(0, synctoken) as \"synctoken!: i64\" from addressobjectchangelog\n WHERE synctoken > ?\n ORDER BY synctoken ASC\n ",
"describe": {
"columns": [
{
"name": "object_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "synctoken!: i64",
"ordinal": 1,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
null
]
},
"hash": "41b415bfb07113cab4dc5d556d39d1d040025c33dfc24e276eb0b2a27ea1799f"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO addressbooks (principal, id, displayname, description, push_topic)\n VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "508adcac37e0d6751924f65e9aed24c0185ce3b1bc39fd0eab7426f581aafe02"
}

View File

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

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO addressobjectchangelog (principal, addressbook_id, object_id, \"operation\", synctoken)\n VALUES (?1, ?2, ?3, ?4, (\n SELECT synctoken FROM addressbooks WHERE (principal, id) = (?1, ?2)\n ))",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "526c4a9326db7f026eee15cca358943b0546fe56fc09204f1dfe90e2614f99b9"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "54c9c0e36a52e6963f11c6aa27f13aafb4204b8aa34b664fd825bd447db80e86"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, vcf FROM addressobjects WHERE principal = ? AND addressbook_id = ? AND deleted_at IS NULL",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "vcf",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false
]
},
"hash": "557344035d762f2d385e505c15288ab9c89c1572f06e7fb61073e335a801b9db"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO calendarobjects (principal, cal_id, id, ics, first_occurence, last_occurence, etag, object_type) VALUES (?, ?, ?, ?, date(?), date(?), ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "6327bee90e5df01536a0ddb15adcc37af3027f6902aa3786365c5ab2fbf06bda"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE calendars\n SET synctoken = synctoken + 1\n WHERE (principal, id) = (?1, ?2)\n RETURNING synctoken",
"describe": {
"columns": [
{
"name": "synctoken",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "69b92e393e55b0d49d1671abf53d06551452846dd94d54ed67d85eb3ace6b568"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO davpush_subscriptions (id, topic, expiration, push_resource, public_key, public_key_type, auth_secret) VALUES (?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "6d08d3a014743da9b445ab012437ec11f81fd86d3b02fc1df07a036c6b47ace2"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO calendarobjectchangelog (principal, cal_id, object_id, \"operation\", synctoken)\n VALUES (?1, ?2, ?3, ?4, (\n SELECT synctoken FROM calendars WHERE (principal, id) = (?1, ?2)\n ))",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "77ad562b6d78115ebb726d58cf1e4de69df169c1872cb452488ff8c43ed983a9"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM addressobjects WHERE addressbook_id = ? AND id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7d4348f04ea5ac82e0f362240fb677740288c963c24b85de11bad011ec5da4bc"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendarobjects SET deleted_at = datetime(), updated_at = datetime() WHERE (principal, cal_id, id) = (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "7e874304170bef19ceb6f96b3d9803ce6d4553cc2bd57b05d1e546e857f995cf"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, topic, expiration, push_resource, public_key, public_key_type, auth_secret\n FROM davpush_subscriptions\n WHERE (topic) = (?)",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "topic",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "expiration",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "push_resource",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "public_key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "public_key_type",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "auth_secret",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "809600b79c012e77c5efabe5d355a8b188f23826a723030807dedc96fd24fdcc"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM memberships WHERE (principal, member_of) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "879e2717335db3b04884fc91173c8507272f1804b27b6a7f61cbe1fbb01265cd"
}

View File

@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, displayname, principal_type, password_hash, json_group_array(member_of) AS \"memberships: Json<Vec<Option<String>>>\"\n FROM (SELECT * FROM principals WHERE id = ?) AS principals\n LEFT JOIN memberships ON principals.id == memberships.principal\n GROUP BY principals.id\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "displayname",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "principal_type",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "password_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "memberships: Json<Vec<Option<String>>>",
"ordinal": 4,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
true,
false,
true,
null
]
},
"hash": "95dce97b2e3224c327690c36777e3ece84a9529551696198b745dd8c743c8a38"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO addressobjects (principal, addressbook_id, id, vcf) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "98dba6ddce38d166ef325bbce6055d83fb0092619262c281a271bdc783a0aed9"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, synctoken, displayname, description, deleted_at, push_topic\n FROM addressbooks\n WHERE principal = ? AND deleted_at IS NULL",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "push_topic",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "9be5d6df7d30a9a85aece59be810bbbb203bab874860aa05eb311259e4baaf05"
}

View File

@@ -1,104 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "timezone",
"ordinal": 7,
"type_info": "Text"
},
{
"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": "push_topic",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "9f930775043a6d4571a8ffd5a981cadf7c51f3f11a189f8461505abec31076e6"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE calendars SET deleted_at = datetime() WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a4371f228f94afd8e6f4ac4b0f7d95b6bf86268b64e714fa7ca587eae9e5df15"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO addressobjects (principal, addressbook_id, id, vcf) VALUES (?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a8b4258868d238d9c226c44aec8c3c4d90d8e3ca526d4d60b340e868bbfd9ddb"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE addressobjects SET deleted_at = datetime(), updated_at = datetime() WHERE (principal, addressbook_id, id) = (?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "ac0f9e29ea8079c6900ffb0ebde699309fe8db1ac9f95c58bc9ce051b7d70299"
}

View File

@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE addressbooks\n SET synctoken = synctoken + 1\n WHERE (principal, id) = (?1, ?2)\n RETURNING synctoken",
"describe": {
"columns": [
{
"name": "synctoken",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false
]
},
"hash": "b89be55d6f76591f68520bbdf0ebb6d688b01ee63ae36936692cf1b4f434c7ee"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects\n WHERE principal = ? AND cal_id = ? AND deleted_at IS NULL\n AND (last_occurence IS NULL OR ? IS NULL OR last_occurence >= date(?))\n AND (first_occurence IS NULL OR ? IS NULL OR first_occurence <= date(?))\n ",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 6
},
"nullable": [
false,
false
]
},
"hash": "c550dbf3d5ce7069f28d767ea9045e477ef8d29d6186851760757a06dec42339"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT DISTINCT object_id, max(0, synctoken) as \"synctoken!: i64\" from calendarobjectchangelog\n WHERE synctoken > ?\n ORDER BY synctoken ASC\n ",
"describe": {
"columns": [
{
"name": "object_id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "synctoken!: i64",
"ordinal": 1,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
null
]
},
"hash": "cbe4be47b2ca1eba485de258f522dec14540a6a9bf383fcde294e8fe14160f22"
}

View File

@@ -1,104 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE principal = ? AND deleted_at IS NOT NULL",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "timezone",
"ordinal": 7,
"type_info": "Text"
},
{
"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": "push_topic",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "cce62f7829bd688cd8c7928b587bc31f0e50865c214b1df113350bea2c254237"
}

View File

@@ -1,104 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE principal = ? AND deleted_at IS NULL",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "order",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "color",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "timezone",
"ordinal": 7,
"type_info": "Text"
},
{
"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": "push_topic",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "comp_event",
"ordinal": 12,
"type_info": "Bool"
},
{
"name": "comp_todo",
"ordinal": 13,
"type_info": "Bool"
},
{
"name": "comp_journal",
"ordinal": 14,
"type_info": "Bool"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
false,
false,
false,
false
]
},
"hash": "cedfb82b38fdd0c7681b9873b1008abee4a2f4ca16abad1b837f256d0bf416b1"
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, ics FROM calendarobjects WHERE (principal, cal_id, id) = (?, ?, ?)",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "ics",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false
]
},
"hash": "d2f7423e2e8f97607f6664200990dcadb927445880ec6edffba3b5aedf4e199b"
}

View File

@@ -1,12 +0,0 @@
{
"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,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM calendarobjects WHERE cal_id = ? AND id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e5cf59ade11c09d90899cc9f87754a78a0ca9f7781fd252ebdaf4d2180fca3ba"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT principal, id, synctoken, displayname, description, deleted_at, push_topic\n FROM addressbooks\n WHERE principal = ? AND deleted_at IS NOT NULL",
"describe": {
"columns": [
{
"name": "principal",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "synctoken",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "displayname",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "description",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "deleted_at",
"ordinal": 5,
"type_info": "Datetime"
},
{
"name": "push_topic",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
true,
true,
true,
false
]
},
"hash": "e5ded4814aae1fc033bb90d27c745f76d3799958d929e8e8d16aa3ceff98e72d"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM davpush_subscriptions WHERE id = ? ",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "e912be3352702bbf035b3ee6e9f239bf75a1ffef20211f1b1e895a67a2310960"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "REPLACE INTO memberships (principal, member_of) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "e947709ba03b108765082d1c4cff3dd8cb485fba5819ac914e20cb8e97037da9"
}

View File

@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT id, topic, expiration, push_resource, public_key, public_key_type, auth_secret\n FROM davpush_subscriptions\n WHERE (id) = (?)",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "topic",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "expiration",
"ordinal": 2,
"type_info": "Datetime"
},
{
"name": "push_resource",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "public_key",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "public_key_type",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "auth_secret",
"ordinal": 6,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "ef6cf801df2237b82b55754f1b0a5da51089810fe7a0feb0d68ea801b4e2721c"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE addressbooks SET deleted_at = datetime() WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "fbd776efdbacf6ce039b5d9760b0de181a6f4e066c52bcac4c106118f5435fe7"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE addressbooks SET deleted_at = NULL WHERE (principal, id) = (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "fcc493f5e491abdac2c2d7d5f636c770e449daf8f5ab4873950ab126edfcce7b"
}

24
Cargo.lock generated
View File

@@ -2703,6 +2703,8 @@ dependencies = [
"rustical_xml",
"serde",
"sha2",
"strum",
"strum_macros",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -2733,6 +2735,8 @@ dependencies = [
"rustical_store",
"rustical_xml",
"serde",
"strum",
"strum_macros",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -2758,6 +2762,7 @@ dependencies = [
"quick-xml",
"rustical_xml",
"serde",
"strum",
"thiserror 2.0.12",
"tokio",
"tower",
@@ -3435,6 +3440,25 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32"
[[package]]
name = "strum_macros"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"

View File

@@ -29,4 +29,4 @@ a CalDAV/CardDAV server
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar (known issue: If a user is member of multiple groups then Apple Calendar just randomly selects a calendar home)
- Apple Calendar

View File

@@ -35,3 +35,5 @@ rustical_ical.workspace = true
http.workspace = true
headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -149,7 +149,7 @@ mod tests {
use super::*;
use crate::calendar_object::{CalendarData, CalendarObjectPropName, ExpandElement};
use calendar_query::{CompFilterElement, FilterElement, TimeRangeElement};
use rustical_dav::xml::PropElement;
use rustical_dav::{extensions::CommonPropertiesPropName, xml::PropElement};
use rustical_ical::UtcDateTime;
use rustical_xml::{NamespaceOwned, ValueDeserialize};
@@ -160,7 +160,6 @@ 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>
@@ -180,7 +179,7 @@ mod tests {
end: <UtcDateTime as ValueDeserialize>::deserialize("20250503T220000Z").unwrap(),
}), limit_recurrence_set: None, limit_freebusy_set: None }
)),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
], vec![])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]
@@ -253,6 +252,7 @@ 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,7 +263,8 @@ mod tests {
ReportRequest::CalendarMultiget(CalendarMultigetRequest {
prop: rustical_dav::xml::PropfindType::Prop(PropElement(vec![
CalendarObjectPropWrapperName::CalendarObject(CalendarObjectPropName::Getetag),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "displayname".to_string())])),
CalendarObjectPropWrapperName::Common(CommonPropertiesPropName::Displayname),
], vec![(Some(NamespaceOwned(Vec::from("DAV:"))), "invalid-prop".to_string())])),
href: vec![
"/caldav/user/user/6f787542-5256-401a-8db97003260da/ae7a998fdfd1d84a20391168962c62b".to_owned()
]

View File

@@ -1,6 +1,7 @@
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 {
@@ -58,39 +59,12 @@ pub struct SupportedCalendarData {
calendar_data: CalendarData,
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
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,5 +1,6 @@
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData, SupportedReportSet};
use super::prop::{SupportedCalendarComponentSet, SupportedCalendarData};
use crate::Error;
use crate::calendar::prop::ReportMethod;
use chrono::{DateTime, Utc};
use derive_more::derive::{From, Into};
use rustical_dav::extensions::{
@@ -7,7 +8,7 @@ use rustical_dav::extensions::{
};
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension;
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
@@ -19,10 +20,6 @@ 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>),
@@ -44,8 +41,8 @@ pub enum CalendarProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
#[xml(skip_deserializing)]
#[xml(ns = "rustical_dav::namespace::NS_CALDAV")]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_CALENDARSERVER")]
Source(Option<HrefElement>),
#[xml(skip_deserializing)]
@@ -127,9 +124,6 @@ impl Resource for CalendarResource {
) -> 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.color.clone())
}
@@ -157,7 +151,7 @@ impl Resource for CalendarResource {
}
CalendarPropName::MaxResourceSize => CalendarProp::MaxResourceSize(10000000),
CalendarPropName::SupportedReportSet => {
CalendarProp::SupportedReportSet(SupportedReportSet::default())
CalendarProp::SupportedReportSet(SupportedReportSet::all())
}
CalendarPropName::Source => CalendarProp::Source(
self.cal.subscription_url.to_owned().map(HrefElement::from),
@@ -187,10 +181,6 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapper::Calendar(prop) => match prop {
CalendarProp::Displayname(displayname) => {
self.cal.displayname = displayname;
Ok(())
}
CalendarProp::CalendarColor(color) => {
self.cal.color = color;
Ok(())
@@ -247,10 +237,6 @@ impl Resource for CalendarResource {
}
match prop {
CalendarPropWrapperName::Calendar(prop) => match prop {
CalendarPropName::Displayname => {
self.cal.displayname = None;
Ok(())
}
CalendarPropName::CalendarColor => {
self.cal.color = None;
Ok(())
@@ -291,6 +277,14 @@ impl Resource for CalendarResource {
}
}
fn get_displayname(&self) -> Option<&str> {
self.cal.displayname.as_deref()
}
fn set_displayname(&mut self, name: Option<String>) -> Result<(), rustical_dav::Error> {
self.cal.displayname = name;
Ok(())
}
fn get_owner(&self) -> Option<&str> {
Some(&self.cal.principal)
}

View File

@@ -50,7 +50,7 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
async fn get_resource(
&self,

View File

@@ -66,6 +66,11 @@ impl Resource for CalendarObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
// TODO: Extract summary from object
None
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}

View File

@@ -22,8 +22,11 @@ pub use error::Error;
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!("{}/principal/{}/", self.0, principal)
format!("{}{}/", self.principal_collection(), principal)
}
}

View File

@@ -2,7 +2,9 @@ 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_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
};
use rustical_store::auth::User;
mod service;
@@ -13,6 +15,7 @@ pub use prop::*;
#[derive(Clone)]
pub struct PrincipalResource {
principal: User,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -32,6 +35,11 @@ impl Resource for PrincipalResource {
Resourcetype(&[
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "collection"),
ResourcetypeInner(Some(rustical_dav::namespace::NS_DAV), "principal"),
// https://github.com/apple/ccs-calendarserver/blob/13c706b985fb728b9aab42dc0fef85aae21921c3/doc/Extensions/caldav-proxy.txt
ResourcetypeInner(
Some(rustical_dav::namespace::NS_CALENDARSERVER),
"calendar-proxy-write",
),
])
}
@@ -43,32 +51,45 @@ impl Resource for PrincipalResource {
) -> 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).into())
.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(home_set),
PrincipalPropName::CalendarHomeSet => {
PrincipalProp::CalendarHomeSet(principal_url.into())
}
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(
@@ -77,6 +98,15 @@ 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)
}

View File

@@ -1,13 +1,14 @@
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet},
};
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),
@@ -17,10 +18,20 @@ pub enum PrincipalProp {
// WebDAV Access Control (RFC 3744)
#[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 = b"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),
CalendarHomeSet(HrefElement),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
@@ -30,5 +41,9 @@ pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlSerialize, PartialEq, Clone, VariantArray)]
pub enum ReportMethod {
// We don't actually support principal-match
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalMatch,
}

View File

@@ -43,7 +43,7 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type Principal = User;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
const DAV_HEADER: &str = "1, 3, access-control, calendar-access, calendar-proxy";
async fn get_resource(
&self,
@@ -54,7 +54,10 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -32,3 +32,5 @@ http.workspace = true
tower-http.workspace = true
percent-encoding.workspace = true
ical.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -64,6 +64,10 @@ impl Resource for AddressObjectResource {
})
}
fn get_displayname(&self) -> Option<&str> {
self.object.get_full_name()
}
fn get_owner(&self) -> Option<&str> {
Some(&self.principal)
}

View File

@@ -1,21 +1,21 @@
use rustical_dav::extensions::{CommonPropertiesProp, SyncTokenExtensionProp};
use rustical_dav::{
extensions::{CommonPropertiesProp, SyncTokenExtensionProp},
xml::SupportedReportSet,
};
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_CARDDAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet),
#[xml(ns = "rustical_dav::namespace::NS_DAV", skip_deserializing)]
SupportedReportSet(SupportedReportSet<ReportMethod>),
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
MaxResourceSize(i64),
}
@@ -60,37 +60,10 @@ impl Default for SupportedAddressData {
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
#[derive(Debug, Clone, XmlSerialize, PartialEq, VariantArray)]
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, SupportedReportSet};
use super::prop::SupportedAddressData;
use crate::Error;
use crate::addressbook::prop::{
AddressbookProp, AddressbookPropName, AddressbookPropWrapper, AddressbookPropWrapperName,
@@ -7,7 +7,7 @@ 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};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension;
use rustical_store::Addressbook;
use rustical_store::auth::User;
@@ -56,14 +56,11 @@ impl Resource for AddressbookResource {
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::default())
AddressbookProp::SupportedReportSet(SupportedReportSet::all())
}
AddressbookPropName::AddressbookDescription => {
AddressbookProp::AddressbookDescription(self.0.description.to_owned())
@@ -89,10 +86,6 @@ 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(())
@@ -113,10 +106,6 @@ 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(())
@@ -135,6 +124,14 @@ 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)
}

View File

@@ -22,8 +22,11 @@ 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!("{}/principal/{}/", self.0, principal)
format!("{}{}/", self.principal_collection(), principal)
}
}

View File

@@ -2,7 +2,9 @@ use crate::Error;
use rustical_dav::extensions::CommonPropertiesExtension;
use rustical_dav::privileges::UserPrivilegeSet;
use rustical_dav::resource::{PrincipalUri, Resource, ResourceName};
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner};
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner,
};
use rustical_store::auth::User;
mod service;
@@ -13,6 +15,7 @@ pub use prop::*;
#[derive(Debug, Clone)]
pub struct PrincipalResource {
principal: User,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -41,30 +44,37 @@ impl Resource for PrincipalResource {
user: &User,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
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(),
);
let principal_href = HrefElement::new(puri.principal_uri(&self.principal.id));
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(home_set)
PrincipalProp::AddressbookHomeSet(principal_href)
}
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())
}
})
}
@@ -74,6 +84,15 @@ 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)
}

View File

@@ -1,23 +1,28 @@
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, 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 = 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 = b"alternate-URI-set")]
AlternateUriSet,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
PrincipalCollectionSet(HrefElement),
// CardDAV (RFC 6352)
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
AddressbookHomeSet(AddressbookHomeSet),
AddressbookHomeSet(HrefElement),
#[xml(ns = "rustical_dav::namespace::NS_CARDDAV")]
PrincipalAddress(Option<HrefElement>),
}

View File

@@ -65,7 +65,10 @@ impl<A: AddressbookStore, AP: AuthenticationProvider, S: SubscriptionStore> Reso
.get_principal(principal)
.await?
.ok_or(crate::Error::NotFound)?;
Ok(PrincipalResource { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -25,3 +25,4 @@ tracing.workspace = true
tokio.workspace = true
http.workspace = true
headers.workspace = true
strum.workspace = true

View File

@@ -13,6 +13,8 @@ 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")]
@@ -37,6 +39,9 @@ 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(),
@@ -52,12 +57,18 @@ pub trait CommonPropertiesExtension: Resource {
})
}
fn set_prop(&self, _prop: CommonPropertiesProp) -> Result<(), crate::Error> {
Err(crate::Error::PropReadOnly)
fn set_prop(&mut self, prop: CommonPropertiesProp) -> Result<(), crate::Error> {
match prop {
CommonPropertiesProp::Displayname(name) => self.set_displayname(name),
_ => Err(crate::Error::PropReadOnly),
}
}
fn remove_prop(&self, _prop: &CommonPropertiesPropName) -> 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),
}
}
}

View File

@@ -60,6 +60,11 @@ 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
}

View File

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

View File

@@ -33,6 +33,10 @@ 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,

View File

@@ -0,0 +1,8 @@
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>);

View File

@@ -11,3 +11,7 @@ pub use tag_list::TagList;
mod error;
pub mod sync_collection;
pub use error::ErrorElement;
mod report_set;
pub use report_set::SupportedReportSet;
mod group;
pub use group::*;

View File

@@ -0,0 +1,34 @@
use rustical_xml::XmlSerialize;
use strum::VariantArray;
// RFC 3253 section-3.1.5
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct SupportedReportSet<T: XmlSerialize + 'static> {
#[xml(flatten)]
#[xml(ns = "crate::namespace::NS_DAV")]
supported_report: Vec<ReportWrapper<T>>,
}
impl<T: XmlSerialize + Clone + 'static> SupportedReportSet<T> {
pub fn new(methods: Vec<T>) -> Self {
Self {
supported_report: methods
.into_iter()
.map(|method| ReportWrapper { report: method })
.collect(),
}
}
pub fn all() -> Self
where
T: VariantArray,
{
Self::new(T::VARIANTS.to_vec())
}
}
#[derive(Debug, Clone, XmlSerialize, PartialEq)]
pub struct ReportWrapper<T: XmlSerialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
report: T,
}

View File

@@ -1,5 +1,8 @@
use derive_more::derive::From;
use quick_xml::name::Namespace;
use quick_xml::{
events::{BytesEnd, BytesStart, Event},
name::Namespace,
};
use rustical_xml::{NamespaceOwned, XmlSerialize};
use std::collections::HashMap;
@@ -9,11 +12,37 @@ pub struct TagList(Vec<(Option<NamespaceOwned>, String)>);
impl XmlSerialize for TagList {
fn serialize<W: std::io::Write>(
&self,
_ns: Option<Namespace>,
_tag: Option<&[u8]>,
_namespaces: &HashMap<Namespace, &[u8]>,
ns: Option<Namespace>,
tag: Option<&[u8]>,
namespaces: &HashMap<Namespace, &[u8]>,
writer: &mut quick_xml::Writer<W>,
) -> std::io::Result<()> {
let prefix = ns
.map(|ns| namespaces.get(&ns))
.unwrap_or(None)
.map(|prefix| {
if !prefix.is_empty() {
[*prefix, b":"].concat()
} else {
Vec::new()
}
});
let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname
.as_ref()
.map(|tagname| ::quick_xml::name::QName(tagname));
if let Some(qname) = &qname {
let mut bytes_start = BytesStart::from(qname.to_owned());
if !has_prefix {
if let Some(ns) = &ns {
bytes_start.push_attribute((b"xmlns".as_ref(), ns.as_ref()));
}
}
writer.write_event(Event::Start(bytes_start))?;
}
for (ns, tag) in &self.0 {
let mut el = writer.create_element(tag);
if let Some(ns) = ns {
@@ -21,6 +50,10 @@ impl XmlSerialize for TagList {
}
el.write_empty()?;
}
if let Some(qname) = &qname {
writer.write_event(Event::End(BytesEnd::from(qname.to_owned())))?;
}
Ok(())
}

View File

@@ -72,9 +72,9 @@ impl AddressObject {
CalDateTime::parse_prop(prop, &HashMap::default()).ok()
}
pub fn get_full_name(&self) -> Option<&String> {
pub fn get_full_name(&self) -> Option<&str> {
let prop = self.vcard.get_property("FN")?;
prop.value.as_ref()
prop.value.as_deref()
}
pub fn get_anniversary_object(&self) -> Result<Option<CalendarObject>, Error> {

View File

@@ -24,6 +24,7 @@ pub trait AuthenticationProvider: Send + Sync + 'static {
async fn add_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>;
async fn remove_membership(&self, principal: &str, member_of: &str) -> Result<(), Error>;
async fn list_members(&self, principal: &str) -> Result<Vec<String>, Error>;
}
pub use middleware::AuthenticationMiddleware;

View File

@@ -108,6 +108,10 @@ impl User {
memberships.push(self.id.as_str());
memberships
}
pub fn memberships_without_self(&self) -> Vec<&str> {
self.memberships.iter().map(String::as_str).collect()
}
}
impl rustical_dav::Principal for User {

View File

@@ -249,4 +249,18 @@ impl AuthenticationProvider for SqlitePrincipalStore {
.map_err(crate::Error::from)?;
Ok(())
}
#[instrument]
async fn list_members(&self, principal: &str) -> Result<Vec<String>, Error> {
Ok(sqlx::query!(
r#"SELECT principal FROM memberships WHERE member_of = ?"#,
principal
)
.fetch_all(&self.db)
.await
.map_err(crate::Error::from)?
.into_iter()
.map(|record| record.principal)
.collect())
}
}

View File

@@ -92,10 +92,17 @@ impl Enum {
let prop_name_variants = tagged_variants.iter().map(|variant| {
let ident = &variant.variant.ident;
let xml_name = variant.xml_name();
if let Some(proptype) = &variant.attrs.prop {
quote! {#ident(#proptype)}
quote! {
#[xml(rename = #xml_name)]
#ident(#proptype)
}
} else {
quote! {#ident}
quote! {
#[xml(rename = #xml_name)]
#ident
}
}
});

View File

@@ -25,7 +25,11 @@ impl Enum {
let prefix = ns
.map(|ns| namespaces.get(&ns))
.unwrap_or(None)
.map(|prefix| [*prefix, b":"].concat());
.map(|prefix| if !prefix.is_empty() {
[*prefix, b":"].concat()
} else {
vec![]
});
let has_prefix = prefix.is_some();
let tagname = tag.map(|tag| [&prefix.unwrap_or_default(), tag].concat());
let qname = tagname.as_ref().map(|tagname| ::quick_xml::name::QName(tagname));

View File

@@ -25,4 +25,4 @@ If you still want to play around with it in its current state, absolutely feel f
- DAVx5,
- GNOME Accounts, GNOME Calendar, GNOME Contacts
- Evolution
- Apple Calendar (known issue: If a user is member of multiple groups then Apple Calendar just randomly selects a calendar home)
- Apple Calendar

View File

@@ -29,8 +29,7 @@ docker run --rm -it -v YOUR_DATA_DIR:/var/lib/rustical/ ghcr.io/lennart-k/rustic
This is also the place to set up **groups**.
Groups and rooms are also just principals and you can specify them as such using the `--principal-type` parameter.
To assign a user to a group you can use the `rustical membership` command. Being a member to a principal means that you can completely act on their behalf and see their collections.
**Note:** Apple Calendar doesn't play well with the current membership implementation so you might not want to set up groups at the moment.
**Note:** Many clients don't support autodiscovery of principals a user is a member of. In that case you'd have to set up multiple CalDAV profiles in your client with the respective principal URLs.
## Password vs app tokens