Compare commits

..

86 Commits

Author SHA1 Message Date
Lennart
734455b5ab version 0.4.7 2025-06-30 20:05:01 +02:00
Lennart
8c6a616015 fix sync-collection limit element 2025-06-30 20:03:54 +02:00
Lennart K
828e7399c8 xml: Make serialization more ergonomic and clippy appeasement 2025-06-29 17:00:10 +02:00
Lennart K
891ef6a9f3 write test fixtures for sqlite store 2025-06-29 12:23:23 +02:00
Lennart
7b27ac22a4 Add Thunderbird to tested clients 2025-06-28 11:43:20 +02:00
Lennart
15668bf399 version 0.4.6 2025-06-28 01:19:29 +02:00
Lennart
d2de87072f slight frontend changes 2025-06-28 01:14:55 +02:00
Lennart
ff1e38477b slight frontend changes 2025-06-28 00:53:24 +02:00
Lennart
f4fbb7c964 Add docs commands to Justfile 2025-06-28 00:07:40 +02:00
Lennart
e8e60d4aac Weaken installation warning since I'm becoming more confident. 2025-06-28 00:06:27 +02:00
Lennart
283be0a26c version 0.4.5 2025-06-27 17:40:48 +02:00
Lennart
1060625b9d fix(oidc): Fix login not working for missing groups claim
see #87
2025-06-27 17:38:42 +02:00
Lennart
86ae31e94c tiny steps towards unit testing for each resource 2025-06-27 14:33:25 +02:00
Lennart
e2f5773e3c Dockerfile: Target Rust 1.88 2025-06-27 14:32:08 +02:00
Lennart
b54fbebe7c store: test preparations 2025-06-27 13:58:14 +02:00
Lennart
fe78a82806 clippy appeasement 2025-06-27 13:57:57 +02:00
Lennart
22544b8c2f Justfile: Add commands to build frontend components 2025-06-27 13:57:44 +02:00
Lennart
340b99e491 Dockerfile: Fix llvm dependency for arm64 builds 2025-06-26 22:25:04 +02:00
Lennart
787ea90376 Dockerfile, update Rust to 1.87+ 2025-06-26 22:03:16 +02:00
Lennart
973a86f21a remove some disabled and broken tests 2025-06-26 19:42:06 +02:00
Lennart
39fc2fb55d principal_type refactoring 2025-06-26 12:50:37 +02:00
Lennart
ab4d763304 tiny improvements to documentation 2025-06-26 12:39:23 +02:00
Lennart
9cf74f7198 frontend: Explicitly mark collections from other groups 2025-06-25 16:14:55 +02:00
Lennart
2c2a6006c7 version 0.4.4 2025-06-25 16:03:31 +02:00
Lennart
4600f03b45 frontend: slight improvements to collection lists 2025-06-25 16:01:17 +02:00
Lennart
41fc1e6ea5 frontend: Remove default red for calendars without color 2025-06-25 15:56:46 +02:00
Lennart
b56591c482 frontend: LSP appeasement 2025-06-25 15:54:47 +02:00
Lennart
d639b18005 version 0.4.3 2025-06-23 16:44:21 +02:00
Lennart
6046439fc7 feat(dav): Add show_deleted parameter to get_resource
Fixes #86
2025-06-23 16:43:46 +02:00
Lennart
f9de8a4687 feat: Add show_deleted to get_calendar 2025-06-23 16:35:36 +02:00
Lennart
8dfb47b28f version 0.4.2 2025-06-23 16:13:18 +02:00
Lennart
eb720ded99 ci: Only tag releases as latest container images 2025-06-23 16:12:36 +02:00
Lennart
89ef7b2ced Update vcard date tests 2025-06-23 16:09:22 +02:00
Lennart
6e0129130e Fix birthdays without year in birthday calendar
Fixes #79
2025-06-23 16:03:59 +02:00
Lennart
c646986c56 Version 0.4.1 2025-06-23 14:08:06 +02:00
Lennart
503cbe3699 fix: Add default frontend config 2025-06-23 14:07:38 +02:00
Lennart
79c66a0b46 fix(caldav): Fix permissions to allow for deletion of calendar subscriptions
fixes #84
2025-06-23 14:04:09 +02:00
Lennart
e5687c6e43 fix(frontend): calendar subscription creation 2025-06-23 14:03:10 +02:00
Lennart
79b67a17c3 Implement deletion button to permanently delete collections 2025-06-23 13:48:00 +02:00
Lennart
7d18faff69 version 0.3.6 2025-06-23 11:21:04 +02:00
Lennart
753f8e90d3 fix(frontend): Fix calendar download link 2025-06-23 11:20:44 +02:00
Lennart
701fa9dd9c Version 3.4.5 2025-06-23 08:54:26 +02:00
Lennart
31b17cfe7f Frontend: Fix dumb typo in calendar creation form
Fixes #82
2025-06-23 08:53:50 +02:00
Lennart
d802a0085a Add Home Assistant to tested clients 2025-06-23 00:42:45 +02:00
Lennart
786b15f5b9 version 0.3.4 2025-06-22 23:58:49 +02:00
Lennart
f5d097ac55 oidc: Fix for OIDC servers not supporting RFC 9207
see #81
2025-06-22 23:55:57 +02:00
Lennart
668fa86e3c Update version to 0.3.3 2025-06-22 21:46:37 +02:00
Lennart
23d2024644 Update note on production-readiness 2025-06-22 19:43:46 +02:00
Lennart
15aadcf1be Rename User struct to Principal 2025-06-19 20:59:59 +02:00
Lennart
4a3b7d7ce6 Update typescript config 2025-06-19 20:52:17 +02:00
Lennart
1a2f3b8f8a frontend: Move collection creation to dialog 2025-06-18 18:09:19 +02:00
Lennart
9e8c218308 Remove unused p256 dependency 2025-06-18 17:49:00 +02:00
Lennart
f2adce739b Update version to v0.3.2 2025-06-15 17:12:34 +02:00
Lennart
0415664ff3 calendar_store: Fix deleted objects being returned 2025-06-15 16:31:07 +02:00
Lennart
677e0082fa multistatus response: Set No-Cache 2025-06-15 13:16:37 +02:00
Lennart
a387885b0a Remove calendar-proxy-write from caldav principal 2025-06-15 11:44:44 +02:00
Lennart
990b953055 Fix typo on store preventing us from deleting calendar objects 2025-06-15 10:37:51 +02:00
Lennart
36b47a645d Fix missing ece backend, finally managed to statically link openssl 2025-06-14 22:26:01 +02:00
Lennart
aa02d11f58 Increase version number to 0.3.0 2025-06-14 20:33:25 +02:00
Lennart
1c31323512 Remove optional dependencies to remove openssl dependency 2025-06-14 20:32:10 +02:00
Lennart
03ae492483 Implement DAV Push 2025-06-14 20:24:50 +02:00
Lennart
0c48507f0c dav: Fix Destination header percent decoding 2025-06-14 16:49:34 +02:00
Lennart
829d4a4385 dav: MOVE/COPY remove origin from Destination header 2025-06-14 15:46:39 +02:00
Lennart
4fe28c5b0f dav: Make MethodFunction public 2025-06-14 15:24:23 +02:00
Lennart
529f36ad99 dav: Convert is_collection const to function which will make filesystem access easier 2025-06-14 15:21:10 +02:00
Lennart
ca5891314c Forgot to commit Cargo.lock 2025-06-14 14:58:33 +02:00
Lennart
e653c68cae Set log level for 404 2025-06-14 14:57:42 +02:00
Lennart
26941c621b Update version to v0.2.2 2025-06-14 14:44:47 +02:00
Lennart
86ab6ef75e dav: Add interface for copy and move 2025-06-14 14:44:10 +02:00
Lennart
0669d4e683 fix dumb mistake 2025-06-13 18:27:16 +02:00
Lennart
0c432d70f9 frontend: Introduce Web Components for forms 2025-06-13 18:24:04 +02:00
Lennart
54997ef865 MKCOL: Set empty displayname to None 2025-06-13 18:23:32 +02:00
Lennart
1a1deeb5a2 mkcalendar: Support subscription url 2025-06-13 18:06:38 +02:00
Lennart
87899738f6 Add dev feature to serve static files from source 2025-06-13 14:57:53 +02:00
Lennart
ab90e5129c Update README.md 2025-06-12 21:06:34 +02:00
Lennart
a9cb397f57 Update README.md 2025-06-12 21:05:37 +02:00
Lennart
35e78bfb44 Update .sqlx files 2025-06-12 21:03:37 +02:00
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
149 changed files with 7977 additions and 2023 deletions

View File

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

1
.gitattributes vendored
View File

@@ -1,2 +1,3 @@
# 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

View File

@@ -41,12 +41,10 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
uses: docker/metadata-action@v5
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
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,3 +12,7 @@ principals.toml
.env
site
# Frontend
**/node_modules
**/.vite

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": "395e40a7b3333b79bc2ad50a123d99f74bc2712a16257ee2119dd211fdb61f7e"
"hash": "246ec675667992c1297c29348d46496a884c59adb8b64b569d36f4ce10f88f47"
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)",
"query": "SELECT *\n FROM calendars\n WHERE (principal, id) = (?, ?)\n AND ((deleted_at IS NULL) OR ?) ",
"describe": {
"columns": [
{
@@ -80,7 +80,7 @@
}
],
"parameters": {
"Right": 2
"Right": 3
},
"nullable": [
false,
@@ -100,5 +100,5 @@
false
]
},
"hash": "9f930775043a6d4571a8ffd5a981cadf7c51f3f11a189f8461505abec31076e6"
"hash": "bb2fa030f2e7c7afdb38c5c54cb31de5293be332d86cf643977d479999542553"
}

760
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
version = "0.4.7"
edition = "2024"
description = "A CalDAV server"
repository = "https://github.com/lennart-k/rustical"
@@ -20,6 +20,7 @@ publish = false
[features]
debug = ["opentelemetry"]
frontend-dev = ["rustical_frontend/dev"]
opentelemetry = [
"dep:opentelemetry",
"dep:opentelemetry-otlp",
@@ -33,6 +34,7 @@ opentelemetry = [
debug = 0
[workspace.dependencies]
matchit = "0.8"
uuid = { version = "1.11", features = ["v4", "fast-rng"] }
async-trait = "0.1"
axum = "0.8"
@@ -132,6 +134,12 @@ 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" }
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 }

View File

@@ -1,11 +1,11 @@
FROM --platform=$BUILDPLATFORM rust:1.86-alpine AS chef
FROM --platform=$BUILDPLATFORM rust:1.88-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="llvm-ar"
ENV AR_aarch64_unknown_linux_musl="llvm20-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 llvm19 clang \
RUN apk add --no-cache musl-dev llvm20 clang perl pkgconf make \
&& rustup target add "$(cat /tmp/rust_target)" \
&& cargo install cargo-chef --locked \
&& rm -rf "$CARGO_HOME/registry"

View File

@@ -1,2 +1,14 @@
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

View File

@@ -3,16 +3,16 @@
a CalDAV/CardDAV server
> [!WARNING]
RustiCal is **not production-ready!**
While I've started migrating to RustiCal and becoming more confident,
please know that bugs and rough edges will still occur.
Concretely, if you are using Apple Calendar you will want to stay away from assigning groups to users.
RustiCal is under **active development**!
While I've been successfully using RustiCal productively for a few weeks now,
you'd still be one of the first testers so expect bugs and rough edges.
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
- ~~[WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5~~ (currently broken)
- also export feature in the frontend
- [WebDAV Push](https://github.com/bitfireAT/webdav-push/) support, so near-instant synchronisation to DAVx5
- 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
@@ -29,4 +29,6 @@ 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
- Home Assistant integration
- Thunderbird

View File

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

View File

@@ -7,6 +7,11 @@ repository.workspace = true
license.workspace = true
publish = false
[dev-dependencies]
rustical_store_sqlite = { workspace = true, features = ["test"] }
rstest.workspace = true
async-std.workspace = true
[dependencies]
axum.workspace = true
axum-extra.workspace = true
@@ -35,3 +40,5 @@ rustical_ical.workspace = true
http.workspace = true
headers.workspace = true
tower-http.workspace = true
strum.workspace = true
strum_macros.workspace = true

View File

@@ -9,7 +9,7 @@ use ical::generator::{Emitter, IcalCalendarBuilder};
use ical::property::Property;
use percent_encoding::{CONTROLS, utf8_percent_encode};
use rustical_ical::{CalendarObjectComponent, EventObject, JournalObject, TodoObject};
use rustical_store::{CalendarStore, SubscriptionStore, auth::User};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
use std::collections::HashMap;
use std::str::FromStr;
use tracing::instrument;
@@ -18,18 +18,22 @@ 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: User,
user: Principal,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
let calendar = cal_store
.get_calendar(&principal, &calendar_id, true)
.await?;
if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
let calendar = cal_store
.get_calendar(&principal, &calendar_id, true)
.await?;
let mut timezones = HashMap::new();
let objects = cal_store.get_objects(&principal, &calendar_id).await?;

View File

@@ -4,8 +4,9 @@ use crate::calendar::prop::SupportedCalendarComponentSet;
use axum::extract::{Path, State};
use axum::response::{IntoResponse, Response};
use http::{Method, StatusCode};
use rustical_dav::xml::HrefElement;
use rustical_ical::CalendarObjectType;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use rustical_store::{Calendar, CalendarStore, SubscriptionStore};
use rustical_xml::{Unparsed, XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument;
@@ -29,6 +30,8 @@ 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)]
@@ -60,7 +63,7 @@ struct MkcolRequest {
#[instrument(skip(cal_store))]
pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
Path((principal, cal_id)): Path<(String, String)>,
user: User,
user: Principal,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
method: Method,
body: String,
@@ -69,12 +72,16 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let request = match method.as_str() {
let mut 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 calendar = Calendar {
id: cal_id.to_owned(),
principal: principal.to_owned(),
@@ -86,7 +93,7 @@ pub async fn route_mkcalendar<C: CalendarStore, S: SubscriptionStore>(
description: request.calendar_description,
deleted_at: None,
synctoken: 0,
subscription_url: None,
subscription_url: request.source.map(|href| href.href),
push_topic: uuid::Uuid::new_v4().to_string(),
components: request
.supported_calendar_component_set

View File

@@ -1,4 +1,4 @@
pub mod mkcalendar;
// pub mod post;
pub mod get;
pub mod mkcalendar;
pub mod post;
pub mod report;

View File

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

@@ -21,7 +21,7 @@ use rustical_dav::{
},
};
use rustical_ical::CalendarObject;
use rustical_store::{CalendarStore, SubscriptionStore, auth::User};
use rustical_store::{CalendarStore, SubscriptionStore, auth::Principal};
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: &User,
user: &Principal,
prop: &PropfindType<CalendarObjectPropWrapperName>,
) -> Result<MultistatusElement<CalendarObjectPropWrapper, String>, Error> {
let mut responses = Vec::new();
@@ -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: User,
user: Principal,
Extension(puri): Extension<CalDavPrincipalUri>,
State(CalendarResourceService { cal_store, .. }): State<CalendarResourceService<C, S>>,
OriginalUri(uri): OriginalUri,

View File

@@ -13,7 +13,7 @@ use rustical_dav::{
};
use rustical_store::{
CalendarStore,
auth::User,
auth::Principal,
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: &User,
user: &Principal,
principal: &str,
cal_id: &str,
cal_store: &C,

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,11 +8,11 @@ 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_push::DavPushExtension;
use rustical_dav::xml::{HrefElement, Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::{DavPushExtension, DavPushExtensionProp};
use rustical_ical::CalDateTime;
use rustical_store::Calendar;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use rustical_xml::{EnumVariants, PropName};
use rustical_xml::{XmlDeserialize, XmlSerialize};
use std::str::FromStr;
@@ -40,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)]
@@ -57,7 +58,7 @@ pub enum CalendarProp {
pub enum CalendarPropWrapper {
Calendar(CalendarProp),
SyncToken(SyncTokenExtensionProp),
// DavPush(DavPushExtensionProp),
DavPush(DavPushExtensionProp),
Common(CommonPropertiesProp),
}
@@ -94,9 +95,11 @@ impl DavPushExtension for CalendarResource {
impl Resource for CalendarResource {
type Prop = CalendarPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
if self.cal.subscription_url.is_none() {
@@ -118,7 +121,7 @@ impl Resource for CalendarResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &CalendarPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -150,7 +153,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),
@@ -165,9 +168,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)?,
),
@@ -225,7 +228,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),
}
}
@@ -269,7 +272,7 @@ 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)
}
@@ -288,8 +291,13 @@ impl Resource for CalendarResource {
Some(&self.cal.principal)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
if self.cal.subscription_url.is_some() || self.read_only {
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 {
return Ok(UserPrivilegeSet::owner_read(
user.is_principal(&self.cal.principal),
));

View File

@@ -1,5 +1,6 @@
use crate::calendar::methods::get::route_get;
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;
@@ -12,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::User;
use rustical_store::auth::Principal;
use rustical_store::{CalendarStore, SubscriptionStore};
use std::convert::Infallible;
use std::sync::Arc;
@@ -47,16 +48,20 @@ impl<C: CalendarStore, S: SubscriptionStore> ResourceService for CalendarResourc
type PathComponents = (String, String); // principal, calendar_id
type Resource = CalendarResource;
type Error = Error;
type Principal = User;
type Principal = Principal;
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, webdav-push";
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).await?;
let calendar = self
.cal_store
.get_calendar(principal, cal_id, show_deleted)
.await?;
Ok(CalendarResource {
cal: calendar,
read_only: self.cal_store.is_read_only(cal_id),
@@ -126,6 +131,13 @@ 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 mkcalendar() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>>
{
Some(|state, req| {

View File

@@ -9,7 +9,7 @@ use headers::{ContentType, ETag, HeaderMapExt, IfNoneMatch};
use http::{HeaderMap, StatusCode};
use rustical_ical::CalendarObject;
use rustical_store::CalendarStore;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use std::str::FromStr;
use tracing::instrument;
@@ -21,19 +21,21 @@ pub async fn get_event<C: CalendarStore>(
object_id,
}): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User,
user: Principal,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(crate::Error::Unauthorized);
}
let calendar = cal_store.get_calendar(&principal, &calendar_id).await?;
let calendar = cal_store
.get_calendar(&principal, &calendar_id, false)
.await?;
if !user.is_principal(&calendar.principal) {
return Err(crate::Error::Unauthorized);
}
let event = cal_store
.get_object(&principal, &calendar_id, &object_id)
.get_object(&principal, &calendar_id, &object_id, false)
.await?;
let mut resp = Response::builder().status(StatusCode::OK);
@@ -51,7 +53,7 @@ pub async fn put_event<C: CalendarStore>(
object_id,
}): Path<CalendarObjectPathComponents>,
State(CalendarObjectResourceService { cal_store }): State<CalendarObjectResourceService<C>>,
user: User,
user: Principal,
mut if_none_match: Option<TypedHeader<IfNoneMatch>>,
header_map: HeaderMap,
body: String,

View File

@@ -8,7 +8,7 @@ use rustical_dav::{
xml::Resourcetype,
};
use rustical_ical::CalendarObject;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
#[derive(Clone, From, Into)]
pub struct CalendarObjectResource {
@@ -25,9 +25,11 @@ impl ResourceName for CalendarObjectResource {
impl Resource for CalendarObjectResource {
type Prop = CalendarObjectPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = false;
fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -36,7 +38,7 @@ impl Resource for CalendarObjectResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &CalendarObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -79,7 +81,7 @@ impl Resource for CalendarObjectResource {
Some(self.object.get_etag())
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &Principal) -> 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::User};
use rustical_store::{CalendarStore, auth::Principal};
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 = User;
type Principal = Principal;
type PrincipalUri = CalDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, calendar-access";
@@ -58,10 +58,11 @@ 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)
.get_object(principal, calendar_id, object_id, show_deleted)
.await?;
Ok(CalendarObjectResource {
object,

View File

@@ -6,7 +6,7 @@ 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, User};
use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc;
@@ -14,7 +14,6 @@ pub mod calendar;
pub mod calendar_object;
pub mod error;
pub mod principal;
// mod subscription;
pub use error::Error;
@@ -45,7 +44,7 @@ pub fn caldav_router<AP: AuthenticationProvider, C: CalendarStore, S: Subscripti
Router::new()
.nest(
prefix,
RootResourceService::<_, User, CalDavPrincipalUri>::new(principal_service.clone())
RootResourceService::<_, Principal, CalDavPrincipalUri>::new(principal_service.clone())
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CalDavPrincipalUri(prefix))),

View File

@@ -2,17 +2,22 @@ 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;
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, Resourcetype, ResourcetypeInner, SupportedReportSet,
};
use rustical_store::auth::Principal;
mod service;
pub use service::*;
mod prop;
pub use prop::*;
#[cfg(test)]
pub mod tests;
#[derive(Clone)]
#[derive(Debug, Clone)]
pub struct PrincipalResource {
principal: User,
principal: Principal,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -24,33 +29,32 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
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",
// ),
])
}
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
let principal_url = puri.principal_uri(&self.principal.id);
let home_set = CalendarHomeSet(
self.principal
.memberships()
.into_iter()
.map(|principal| puri.principal_uri(principal).into())
.collect(),
);
Ok(match prop {
PrincipalPropWrapperName::Principal(prop) => {
PrincipalPropWrapper::Principal(match prop {
@@ -60,10 +64,20 @@ impl Resource for PrincipalResource {
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
@@ -74,10 +88,11 @@ impl Resource for PrincipalResource {
))
}
PrincipalPropName::AlternateUriSet => PrincipalProp::AlternateUriSet,
PrincipalPropName::PrincipalCollectionSet => {
PrincipalProp::PrincipalCollectionSet(PrincipalCollectionSet(
puri.principal_collection().into(),
))
// PrincipalPropName::PrincipalCollectionSet => {
// PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
// }
PrincipalPropName::SupportedReportSet => {
PrincipalProp::SupportedReportSet(SupportedReportSet::all())
}
})
}
@@ -100,7 +115,7 @@ impl Resource for PrincipalResource {
Some(&self.principal.id)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_read(
user.is_principal(&self.principal.id),
))

View File

@@ -1,6 +1,10 @@
use rustical_dav::{extensions::CommonPropertiesProp, xml::HrefElement};
use rustical_store::auth::user::PrincipalType;
use rustical_dav::{
extensions::CommonPropertiesProp,
xml::{GroupMemberSet, GroupMembership, HrefElement, SupportedReportSet},
};
use rustical_store::auth::PrincipalType;
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlSerialize};
use strum_macros::VariantArray;
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone, EnumVariants, PropName)]
#[xml(unit_variants_ident = "PrincipalPropName")]
@@ -16,14 +20,18 @@ pub enum PrincipalProp {
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(PrincipalCollectionSet),
// #[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)]
@@ -33,11 +41,9 @@ pub enum PrincipalPropWrapper {
Common(CommonPropertiesProp),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct CalendarHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct PrincipalCollectionSet(#[xml(ty = "untagged")] pub(super) 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

@@ -5,7 +5,7 @@ use crate::{CalDavPrincipalUri, Error};
use async_trait::async_trait;
use axum::Router;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::{AuthenticationProvider, User};
use rustical_store::auth::{AuthenticationProvider, Principal};
use rustical_store::{CalendarStore, SubscriptionStore};
use std::sync::Arc;
@@ -40,21 +40,25 @@ impl<AP: AuthenticationProvider, S: SubscriptionStore, CS: CalendarStore> Resour
type MemberType = CalendarResource;
type Resource = PrincipalResource;
type Error = Error;
type Principal = User;
type Principal = Principal;
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,
(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 { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -0,0 +1,46 @@
use std::sync::Arc;
use crate::principal::PrincipalResourceService;
use rstest::rstest;
use rustical_dav::resource::ResourceService;
use rustical_store_sqlite::{
SqliteStore,
calendar_store::SqliteCalendarStore,
principal_store::SqlitePrincipalStore,
tests::{get_test_calendar_store, get_test_principal_store, get_test_subscription_store},
};
#[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),
};
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() {}

View File

@@ -1,33 +0,0 @@
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

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

@@ -12,7 +12,7 @@ use rustical_dav::privileges::UserPrivilege;
use rustical_dav::resource::Resource;
use rustical_ical::AddressObject;
use rustical_store::AddressbookStore;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use std::str::FromStr;
use tracing::instrument;
@@ -24,7 +24,7 @@ pub async fn get_object<AS: AddressbookStore>(
object_id,
}): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User,
user: Principal,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
@@ -60,7 +60,7 @@ pub async fn put_object<AS: AddressbookStore>(
object_id,
}): Path<AddressObjectPathComponents>,
State(AddressObjectResourceService { addr_store }): State<AddressObjectResourceService<AS>>,
user: User,
user: Principal,
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::User;
use rustical_store::auth::Principal;
#[derive(Clone, From, Into)]
pub struct AddressObjectResource {
@@ -30,9 +30,11 @@ impl ResourceName for AddressObjectResource {
impl Resource for AddressObjectResource {
type Prop = AddressObjectPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = false;
fn is_collection(&self) -> bool {
false
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[])
@@ -41,7 +43,7 @@ impl Resource for AddressObjectResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &AddressObjectPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -76,7 +78,7 @@ impl Resource for AddressObjectResource {
Some(self.object.get_etag())
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &Principal) -> 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::User};
use rustical_store::{AddressbookStore, auth::Principal};
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 = User;
type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -49,10 +49,11 @@ 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, false)
.get_object(principal, addressbook_id, object_id, show_deleted)
.await?;
Ok(AddressObjectResource {
object,

View File

@@ -10,7 +10,7 @@ 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::User;
use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::str::FromStr;
use tracing::instrument;
@@ -19,7 +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: User,
user: Principal,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);

View File

@@ -4,7 +4,7 @@ use axum::{
response::{IntoResponse, Response},
};
use http::StatusCode;
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::User};
use rustical_store::{Addressbook, AddressbookStore, SubscriptionStore, auth::Principal};
use rustical_xml::{XmlDeserialize, XmlDocument, XmlRootTag};
use tracing::instrument;
@@ -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: User,
user: Principal,
State(AddressbookResourceService { addr_store, .. }): State<AddressbookResourceService<AS, S>>,
body: String,
) -> Result<Response, Error> {
@@ -52,8 +52,10 @@ pub async fn route_mkcol<AS: AddressbookStore, S: SubscriptionStore>(
return Err(Error::Unauthorized);
}
let request = MkcolRequest::parse_str(&body)?;
let request = request.set.prop;
let mut request = MkcolRequest::parse_str(&body)?.set.prop;
if let Some("") = request.displayname.as_deref() {
request.displayname = None
}
let addressbook = Addressbook {
id: addressbook_id.to_owned(),

View File

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

View File

@@ -1,33 +1,40 @@
use crate::Error;
use crate::addressbook::resource::AddressbookResourceService;
use actix_web::http::header;
use actix_web::web::{Data, Path};
use actix_web::{HttpRequest, HttpResponse};
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 rustical_dav_push::register::PushRegister;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, Subscription, SubscriptionStore};
use rustical_xml::XmlDocument;
use tracing::instrument;
use tracing_actix_web::RootSpan;
#[instrument(parent = root_span.id(), skip(resource_service, root_span, req))]
pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
path: Path<(String, String)>,
#[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>>,
body: String,
user: User,
resource_service: Data<AddressbookResourceService<A, S>>,
root_span: RootSpan,
req: HttpRequest,
) -> Result<HttpResponse, Error> {
let (principal, addressbook_id) = path.into_inner();
) -> Result<Response, Error> {
if !user.is_principal(&principal) {
return Err(Error::Unauthorized);
}
let addressbook = resource_service
.addr_store
.get_addressbook(&principal, &addressbook_id, false)
.get_addressbook(&principal, &addr_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();
@@ -44,7 +51,7 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.web_push_subscription
.push_resource
.to_owned(),
topic: addressbook.push_topic,
topic: addressbook_resource.0.push_topic,
expiration: expires.naive_local(),
public_key: request
.subscription
@@ -63,13 +70,17 @@ pub async fn route_post<A: AddressbookStore, S: SubscriptionStore>(
.upsert_subscription(subscription)
.await?;
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())
// 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())
}

View File

@@ -9,14 +9,14 @@ use http::StatusCode;
use ical::VcardParser;
use rustical_ical::AddressObject;
use rustical_store::Addressbook;
use rustical_store::{AddressbookStore, SubscriptionStore, auth::User};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
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,
user: Principal,
body: String,
) -> Result<Response, Error> {
if !user.is_principal(&principal) {

View File

@@ -10,7 +10,7 @@ use rustical_dav::{
xml::{MultistatusElement, PropfindType, multistatus::ResponseElement},
};
use rustical_ical::AddressObject;
use rustical_store::{AddressbookStore, auth::User};
use rustical_store::{AddressbookStore, auth::Principal};
use rustical_xml::XmlDeserialize;
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
@@ -58,12 +58,13 @@ 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: &User,
user: &Principal,
principal: &str,
cal_id: &str,
addr_store: &AS,

View File

@@ -9,7 +9,7 @@ use axum::{
response::IntoResponse,
};
use rustical_dav::xml::{PropfindType, sync_collection::SyncCollectionRequest};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::User};
use rustical_store::{AddressbookStore, SubscriptionStore, auth::Principal};
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: User,
user: Principal,
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::User,
auth::Principal,
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: &User,
user: &Principal,
principal: &str,
addressbook_id: &str,
addr_store: &AS,

View File

@@ -1,6 +1,10 @@
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")]
@@ -10,8 +14,8 @@ pub enum AddressbookProp {
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),
}
@@ -56,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,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};
use rustical_dav::xml::{Resourcetype, ResourcetypeInner, SupportedReportSet};
use rustical_dav_push::DavPushExtension;
use rustical_store::Addressbook;
use rustical_store::auth::User;
use rustical_store::auth::Principal;
#[derive(Clone, Debug, From, Into)]
pub struct AddressbookResource(pub(crate) Addressbook);
@@ -36,9 +36,11 @@ impl DavPushExtension for AddressbookResource {
impl Resource for AddressbookResource {
type Prop = AddressbookPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -50,7 +52,7 @@ impl Resource for AddressbookResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &AddressbookPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
Ok(match prop {
@@ -60,7 +62,7 @@ impl Resource for AddressbookResource {
AddressbookProp::MaxResourceSize(10000000)
}
AddressbookPropName::SupportedReportSet => {
AddressbookProp::SupportedReportSet(SupportedReportSet::default())
AddressbookProp::SupportedReportSet(SupportedReportSet::all())
}
AddressbookPropName::AddressbookDescription => {
AddressbookProp::AddressbookDescription(self.0.description.to_owned())
@@ -136,7 +138,7 @@ impl Resource for AddressbookResource {
Some(&self.0.principal)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.0.principal),
))

View File

@@ -3,6 +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::post::route_post;
use crate::addressbook::methods::put::route_put;
use crate::addressbook::resource::AddressbookResource;
use crate::{CardDavPrincipalUri, Error};
@@ -13,7 +14,7 @@ use axum::handler::Handler;
use axum::response::Response;
use futures_util::future::BoxFuture;
use rustical_dav::resource::{AxumMethods, ResourceService};
use rustical_store::auth::User;
use rustical_store::auth::Principal;
use rustical_store::{AddressbookStore, SubscriptionStore};
use std::convert::Infallible;
use std::sync::Arc;
@@ -50,18 +51,19 @@ impl<AS: AddressbookStore, S: SubscriptionStore> ResourceService
type PathComponents = (String, String); // principal, addressbook_id
type Resource = AddressbookResource;
type Error = Error;
type Principal = User;
type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
const DAV_HEADER: &str = "1, 3, access-control, addressbook, webdav-push";
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, false)
.get_addressbook(principal, addressbook_id, show_deleted)
.await
.map_err(|_e| Error::NotFound)?;
Ok(addressbook.into())
@@ -130,6 +132,13 @@ impl<AS: AddressbookStore, S: SubscriptionStore> AxumMethods for AddressbookReso
})
}
fn post() -> 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 put() -> Option<fn(Self, Request) -> BoxFuture<'static, Result<Response, Infallible>>> {
Some(|state, req| {
let mut service = Handler::with_state(route_put::<AS, S>, state);

View File

@@ -9,7 +9,7 @@ use rustical_dav::resources::RootResourceService;
use rustical_store::auth::middleware::AuthenticationLayer;
use rustical_store::{
AddressbookStore, SubscriptionStore,
auth::{AuthenticationProvider, User},
auth::{AuthenticationProvider, Principal},
};
use std::sync::Arc;
@@ -44,10 +44,12 @@ pub fn carddav_router<AP: AuthenticationProvider, A: AddressbookStore, S: Subscr
Router::new()
.nest(
prefix,
RootResourceService::<_, User, CardDavPrincipalUri>::new(principal_service.clone())
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix))),
RootResourceService::<_, Principal, CardDavPrincipalUri>::new(
principal_service.clone(),
)
.axum_router()
.layer(AuthenticationLayer::new(auth_provider))
.layer(Extension(CardDavPrincipalUri(prefix))),
)
.route(
"/.well-known/carddav",

View File

@@ -2,8 +2,10 @@ 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_store::auth::User;
use rustical_dav::xml::{
GroupMemberSet, GroupMembership, HrefElement, Resourcetype, ResourcetypeInner,
};
use rustical_store::auth::Principal;
mod service;
pub use service::*;
@@ -12,7 +14,8 @@ pub use prop::*;
#[derive(Debug, Clone)]
pub struct PrincipalResource {
principal: User,
principal: Principal,
members: Vec<String>,
}
impl ResourceName for PrincipalResource {
@@ -24,9 +27,11 @@ impl ResourceName for PrincipalResource {
impl Resource for PrincipalResource {
type Prop = PrincipalPropWrapper;
type Error = Error;
type Principal = User;
type Principal = Principal;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[
@@ -38,26 +43,17 @@ impl Resource for PrincipalResource {
fn get_prop(
&self,
puri: &impl PrincipalUri,
user: &User,
user: &Principal,
prop: &PrincipalPropWrapperName,
) -> Result<Self::Prop, Self::Error> {
let principal_href = HrefElement::new(puri.principal_uri(&user.id));
let home_set = AddressbookHomeSet(
self.principal
.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::PrincipalUrl => PrincipalProp::PrincipalUrl(principal_href),
PrincipalPropName::AddressbookHomeSet => {
PrincipalProp::AddressbookHomeSet(home_set)
PrincipalProp::AddressbookHomeSet(principal_href)
}
PrincipalPropName::PrincipalAddress => PrincipalProp::PrincipalAddress(None),
PrincipalPropName::GroupMembership => {
@@ -69,11 +65,17 @@ impl Resource for PrincipalResource {
.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(PrincipalCollectionSet(
puri.principal_collection().into(),
))
PrincipalProp::PrincipalCollectionSet(puri.principal_collection().into())
}
})
}
@@ -97,7 +99,7 @@ impl Resource for PrincipalResource {
Some(&self.principal.id)
}
fn get_user_privileges(&self, user: &User) -> Result<UserPrivilegeSet, Self::Error> {
fn get_user_privileges(&self, user: &Principal) -> Result<UserPrivilegeSet, Self::Error> {
Ok(UserPrivilegeSet::owner_only(
user.is_principal(&self.principal.id),
))

View File

@@ -1,4 +1,7 @@
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, EnumVariants, PropName)]
@@ -10,14 +13,16 @@ pub enum PrincipalProp {
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(PrincipalCollectionSet),
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>),
}
@@ -28,12 +33,3 @@ pub enum PrincipalPropWrapper {
Principal(PrincipalProp),
Common(CommonPropertiesProp),
}
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct AddressbookHomeSet(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct GroupMembership(#[xml(ty = "untagged", flatten)] pub(super) Vec<HrefElement>);
#[derive(XmlDeserialize, XmlSerialize, PartialEq, Clone)]
pub struct PrincipalCollectionSet(#[xml(ty = "untagged")] pub(super) HrefElement);

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, User};
use rustical_store::auth::{AuthenticationProvider, Principal};
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 = User;
type Principal = Principal;
type PrincipalUri = CardDavPrincipalUri;
const DAV_HEADER: &str = "1, 3, access-control, addressbook";
@@ -59,13 +59,17 @@ 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 { principal: user })
Ok(PrincipalResource {
members: self.auth_provider.list_members(&user.id).await?,
principal: user,
})
}
async fn get_members(

View File

@@ -25,3 +25,6 @@ tracing.workspace = true
tokio.workspace = true
http.workspace = true
headers.workspace = true
strum.workspace = true
matchit.workspace = true
matchit-serde.workspace = true

View File

@@ -28,6 +28,9 @@ pub enum Error {
#[error("Precondition Failed")]
PreconditionFailed,
#[error("Forbidden")]
Forbidden,
}
impl Error {
@@ -49,6 +52,7 @@ impl Error {
Error::PropReadOnly => StatusCode::CONFLICT,
Error::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
Self::IOError(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Forbidden => StatusCode::FORBIDDEN,
}
}
}

View File

@@ -2,6 +2,7 @@ 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)]
pub enum UserPrivilege {
Read,
@@ -47,6 +48,12 @@ 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)
}
@@ -72,6 +79,15 @@ 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([
@@ -81,6 +97,17 @@ 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

@@ -1,25 +1,54 @@
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,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> {
// 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())
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.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
}

View File

@@ -45,10 +45,11 @@ 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).await?;
let resource = resource_service.get_resource(path_components, true).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::Write) {
if !privileges.has(&UserPrivilege::WriteProperties) {
return Err(Error::Unauthorized.into());
}

View File

@@ -1,25 +1,54 @@
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,
matched_path: MatchedPath,
header_map: HeaderMap,
) -> Result<Response, R::Error> {
// 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())
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.is_true())
.await?
{
// Overwritten
Ok(StatusCode::NO_CONTENT.into_response())
} else {
// Not overwritten
Ok(StatusCode::CREATED.into_response())
}
} else {
Ok(StatusCode::FORBIDDEN.into_response())
}
}

View File

@@ -49,7 +49,9 @@ 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).await?;
let resource = resource_service
.get_resource(path_components, false)
.await?;
let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Read) {
return Err(Error::Unauthorized.into());

View File

@@ -85,7 +85,9 @@ pub(crate) async fn route_proppatch<R: ResourceService>(
operations,
) = XmlDocument::parse_str(body).map_err(Error::XmlError)?;
let mut resource = resource_service.get_resource(path_components).await?;
let mut resource = resource_service
.get_resource(path_components, false)
.await?;
let privileges = resource.get_user_privileges(principal)?;
if !privileges.has(&UserPrivilege::Write) {
return Err(Error::Unauthorized.into());

View File

@@ -18,7 +18,7 @@ mod methods;
mod principal_uri;
mod resource_service;
pub use axum_methods::AxumMethods;
pub use axum_methods::{AxumMethods, MethodFunction};
pub use axum_service::AxumService;
pub use principal_uri::PrincipalUri;
@@ -37,7 +37,7 @@ pub trait Resource: Clone + Send + 'static {
type Error: From<crate::Error>;
type Principal: Principal;
const IS_COLLECTION: bool;
fn is_collection(&self) -> bool;
fn get_resourcetype(&self) -> Resourcetype;
@@ -111,7 +111,7 @@ pub trait Resource: Clone + Send + 'static {
) -> 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('/');
}

View File

@@ -9,7 +9,13 @@ use serde::Deserialize;
#[async_trait]
pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
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 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 MemberType: Resource<Error = Self::Error, Principal = Self::Principal>
+ super::ResourceName;
type Resource: Resource<Error = Self::Error, Principal = Self::Principal>;
@@ -28,7 +34,8 @@ pub trait ResourceService: Clone + Sized + Send + Sync + AxumMethods + 'static {
async fn get_resource(
&self,
_path: &Self::PathComponents,
path: &Self::PathComponents,
show_deleted: bool,
) -> Result<Self::Resource, Self::Error>;
async fn save_resource(
@@ -47,6 +54,28 @@ 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

@@ -24,7 +24,9 @@ impl<PR: Resource, P: Principal> Resource for RootResource<PR, P> {
type Error = PR::Error;
type Principal = P;
const IS_COLLECTION: bool = true;
fn is_collection(&self) -> bool {
true
}
fn get_resourcetype(&self) -> Resourcetype {
Resourcetype(&[ResourcetypeInner(
@@ -84,7 +86,11 @@ where
const DAV_HEADER: &str = "1, 3, access-control";
async fn get_resource(&self, _: &()) -> Result<Self::Resource, Self::Error> {
async fn get_resource(
&self,
_: &(),
_show_deleted: bool,
) -> Result<Self::Resource, Self::Error> {
Ok(RootResource::<PRS::Resource, P>::default())
}

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

@@ -1,4 +1,5 @@
use crate::xml::TagList;
use headers::{CacheControl, ContentType, HeaderMapExt};
use http::StatusCode;
use quick_xml::name::Namespace;
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
@@ -109,18 +110,16 @@ impl<T1: XmlSerialize, T2: XmlSerialize> axum::response::IntoResponse
{
fn into_response(self) -> axum::response::Response {
use axum::body::Body;
use http::header;
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = self.serialize_root(&mut writer) {
return crate::Error::from(err).into_response();
}
let output = match self.serialize_to_string() {
Ok(out) => out,
Err(err) => return crate::Error::from(err).into_response(),
};
let mut resp = axum::response::Response::builder().status(StatusCode::MULTI_STATUS);
resp.headers_mut()
.unwrap()
.insert(header::CONTENT_TYPE, "application/xml".try_into().unwrap());
let hdrs = resp.headers_mut().unwrap();
hdrs.typed_insert(ContentType::xml());
hdrs.typed_insert(CacheControl::new().with_no_cache());
resp.body(Body::from(output)).unwrap()
}
}

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

@@ -23,20 +23,17 @@ mod tests {
#[test]
fn test_serialize_resourcetype() {
let mut buf = Vec::new();
let mut writer = quick_xml::Writer::new(&mut buf);
Document {
let out = Document {
resourcetype: Resourcetype(&[
ResourcetypeInner(Some(crate::namespace::NS_DAV), "displayname"),
ResourcetypeInner(Some(crate::namespace::NS_CALENDARSERVER), "calendar-color"),
]),
}
.serialize_root(&mut writer)
.serialize_to_string()
.unwrap();
let out = String::from_utf8(buf).unwrap();
assert_eq!(
out,
"<document><resourcetype><displayname xmlns=\"DAV:\"/><calendar-color xmlns=\"http://calendarserver.org/ns/\"/></resourcetype></document>"
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<document><resourcetype><displayname xmlns=\"DAV:\"/><calendar-color xmlns=\"http://calendarserver.org/ns/\"/></resourcetype></document>"
)
}
}

View File

@@ -1,4 +1,4 @@
use rustical_xml::{ValueDeserialize, ValueSerialize, XmlDeserialize};
use rustical_xml::{ValueDeserialize, ValueSerialize, XmlDeserialize, XmlRootTag};
use super::PropfindType;
@@ -32,11 +32,35 @@ impl ValueSerialize for SyncLevel {
}
}
// https://datatracker.ietf.org/doc/html/rfc5323#section-5.17
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct LimitElement {
#[xml(ns = "crate::namespace::NS_DAV")]
pub nresults: NresultsElement,
}
impl From<u64> for LimitElement {
fn from(value: u64) -> Self {
Self {
nresults: NresultsElement(value),
}
}
}
impl From<LimitElement> for u64 {
fn from(value: LimitElement) -> Self {
value.nresults.0
}
}
#[derive(XmlDeserialize, Clone, Debug, PartialEq)]
pub struct NresultsElement(#[xml(ty = "text")] u64);
#[derive(XmlDeserialize, Clone, Debug, PartialEq, XmlRootTag)]
// <!ELEMENT sync-collection (sync-token, sync-level, limit?, prop)>
// <!-- DAV:limit defined in RFC 5323, Section 5.17 -->
// <!-- DAV:prop defined in RFC 4918, Section 14.18 -->
#[xml(ns = "crate::namespace::NS_DAV")]
#[xml(ns = "crate::namespace::NS_DAV", root = b"sync-collection")]
pub struct SyncCollectionRequest<PN: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV")]
pub sync_token: String,
@@ -45,5 +69,48 @@ pub struct SyncCollectionRequest<PN: XmlDeserialize> {
#[xml(ns = "crate::namespace::NS_DAV", ty = "untagged")]
pub prop: PropfindType<PN>,
#[xml(ns = "crate::namespace::NS_DAV")]
pub limit: Option<u64>,
pub limit: Option<LimitElement>,
}
#[cfg(test)]
mod tests {
use crate::xml::{
PropElement, PropfindType,
sync_collection::{SyncCollectionRequest, SyncLevel},
};
use rustical_xml::{EnumVariants, PropName, XmlDeserialize, XmlDocument};
const SYNC_COLLECTION_REQUEST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<sync-collection xmlns="DAV:">
<sync-token />
<sync-level>1</sync-level>
<limit>
<nresults>100</nresults>
</limit>
<prop>
<getetag />
</prop>
</sync-collection>
"#;
#[derive(XmlDeserialize, PropName, EnumVariants, PartialEq)]
#[xml(unit_variants_ident = "TestPropName")]
enum TestProp {
Getetag(String),
}
#[test]
fn test_parse_sync_collection_request() {
let request =
SyncCollectionRequest::<TestPropName>::parse_str(SYNC_COLLECTION_REQUEST).unwrap();
assert_eq!(
request,
SyncCollectionRequest {
sync_token: "".to_owned(),
sync_level: SyncLevel::One,
prop: PropfindType::Prop(PropElement(vec![TestPropName::Getetag], vec![])),
limit: Some(100.into())
}
)
}
}

View File

@@ -23,3 +23,8 @@ tokio.workspace = true
rustical_dav.workspace = true
rustical_store.workspace = true
http.workspace = true
base64.workspace = true
rand.workspace = true
ece.workspace = true
axum.workspace = true
openssl.workspace = true

View File

@@ -0,0 +1,23 @@
use axum::{
Router,
extract::{Path, State},
response::{IntoResponse, Response},
routing::delete,
};
use http::StatusCode;
use rustical_store::SubscriptionStore;
use std::sync::Arc;
async fn handle_delete<S: SubscriptionStore>(
State(store): State<Arc<S>>,
Path(id): Path<String>,
) -> Result<Response, rustical_store::Error> {
store.delete_subscription(&id).await?;
Ok((StatusCode::NO_CONTENT, "Unregistered").into_response())
}
pub fn subscription_service<S: SubscriptionStore>(sub_store: Arc<S>) -> Router {
Router::new()
.route("/push_subscription/{id}", delete(handle_delete::<S>))
.with_state(sub_store)
}

View File

@@ -1,14 +1,41 @@
mod extension;
pub mod notifier;
mod prop;
pub mod register;
use base64::Engine;
use derive_more::Constructor;
pub use extension::*;
use http::{HeaderValue, Method, header};
pub use prop::*;
use rustical_store::{CollectionOperation, SubscriptionStore};
use std::sync::Arc;
use reqwest::{Body, Url};
use rustical_store::{
CollectionOperation, CollectionOperationInfo, Subscription, SubscriptionStore,
};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{collections::HashMap, sync::Arc, time::Duration};
use tokio::sync::mpsc::Receiver;
use tracing::error;
use tracing::{error, warn};
mod endpoints;
pub use endpoints::subscription_service;
#[derive(XmlSerialize, Debug)]
pub struct ContentUpdate {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAVPUSH")]
content_update: Option<ContentUpdate>,
}
#[derive(Debug, Constructor)]
pub struct DavPushController<S: SubscriptionStore> {
@@ -18,14 +45,176 @@ pub struct DavPushController<S: SubscriptionStore> {
impl<S: SubscriptionStore> DavPushController<S> {
pub async fn notifier(&self, mut recv: Receiver<CollectionOperation>) {
while let Some(message) = recv.recv().await {
let subscribers = match self.sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
continue;
loop {
// Make sure we don't flood the subscribers
tokio::time::sleep(Duration::from_secs(10)).await;
let mut messages = vec![];
recv.recv_many(&mut messages, 100).await;
// Right now we just have to show the latest content update by topic
// This might become more complicated in the future depending on what kind of updates
// we add
let mut latest_messages = HashMap::new();
for message in messages {
if matches!(message.data, CollectionOperationInfo::Content { .. }) {
latest_messages.insert(message.topic.to_string(), message);
}
};
}
let messages = latest_messages.into_values();
for message in messages {
self.send_message(message).await;
}
}
}
async fn send_message(&self, message: CollectionOperation) {
let subscriptions = match self.sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
return;
}
};
if subscriptions.is_empty() {
return;
}
if matches!(message.data, CollectionOperationInfo::Delete) {
// Collection has been deleted, but we cannot handle that
return;
}
let content_update = if let CollectionOperationInfo::Content { sync_token } = message.data {
Some(ContentUpdate {
sync_token: Some(sync_token),
})
} else {
None
};
let push_message = PushMessage {
topic: message.topic,
content_update,
};
let payload = match push_message.serialize_to_string() {
Ok(payload) => payload,
Err(err) => {
error!("Could not serialize push message: {}", err);
return;
}
};
for subsciption in subscriptions {
if let Some(allowed_push_servers) = &self.allowed_push_servers {
if let Ok(url) = Url::parse(&subsciption.push_resource) {
let origin = url.origin().unicode_serialization();
if !allowed_push_servers.contains(&origin) {
warn!(
"Deleting subscription {} on topic {} because the endpoint is not in the list of allowed push servers",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
} else {
warn!(
"Deleting subscription {} on topic {} because of invalid URL",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
};
}
if let Err(err) = self.send_payload(&payload, &subsciption).await {
error!("An error occured sending out a push notification: {err}");
if err.is_permament_error() {
warn!(
"Deleting subscription {} on topic {}",
subsciption.id, subsciption.topic
);
self.try_delete_subscription(&subsciption.id).await;
}
}
}
}
async fn try_delete_subscription(&self, sub_id: &str) {
if let Err(err) = self.sub_store.delete_subscription(sub_id).await {
error!("Error deleting subsciption: {err}");
}
}
async fn send_payload(
&self,
payload: &str,
subsciption: &Subscription,
) -> Result<(), NotifierError> {
if subsciption.public_key_type != "p256dh" {
return Err(NotifierError::InvalidPublicKeyType(
subsciption.public_key_type.to_string(),
));
}
let endpoint = subsciption.push_resource.parse().map_err(|_| {
NotifierError::InvalidEndpointUrl(subsciption.push_resource.to_string())
})?;
let ua_public = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.public_key)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let auth_secret = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&subsciption.auth_secret)
.map_err(|_| NotifierError::InvalidKeyEncoding)?;
let client = reqwest::ClientBuilder::new()
.build()
.map_err(NotifierError::from)?;
let payload = ece::encrypt(&ua_public, &auth_secret, payload.as_bytes())?;
let mut request = reqwest::Request::new(Method::POST, endpoint);
*request.body_mut() = Some(Body::from(payload));
let hdrs = request.headers_mut();
hdrs.insert(
header::CONTENT_ENCODING,
HeaderValue::from_static("aes128gcm"),
);
hdrs.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/octet-stream"),
);
client.execute(request).await?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
enum NotifierError {
#[error("Invalid public key type: {0}")]
InvalidPublicKeyType(String),
#[error("Invalid endpoint URL: {0}")]
InvalidEndpointUrl(String),
#[error("Invalid key encoding")]
InvalidKeyEncoding,
#[error(transparent)]
EceError(#[from] ece::Error),
#[error(transparent)]
ReqwestError(#[from] reqwest::Error),
}
impl NotifierError {
// Decide whether the error should cause the subscription to be removed
pub fn is_permament_error(&self) -> bool {
match self {
Self::InvalidPublicKeyType(_)
| Self::InvalidEndpointUrl(_)
| Self::InvalidKeyEncoding => true,
Self::EceError(err) => matches!(
err,
ece::Error::InvalidAuthSecret | ece::Error::InvalidKeyLength
),
Self::ReqwestError(_) => false,
}
}
}

View File

@@ -1,147 +0,0 @@
use http::StatusCode;
use reqwest::{
Method, Request,
header::{self, HeaderName, HeaderValue},
};
use rustical_dav::xml::multistatus::PropstatElement;
use rustical_store::{CollectionOperation, CollectionOperationType, SubscriptionStore};
use rustical_xml::{XmlRootTag, XmlSerialize, XmlSerializeRoot};
use std::{str::FromStr, sync::Arc};
use tokio::sync::mpsc::Receiver;
use tracing::{error, info, warn};
// use web_push::{SubscriptionInfo, WebPushMessage, WebPushMessageBuilder};
#[derive(XmlSerialize, Debug)]
struct PushMessageProp {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
topic: String,
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
sync_token: Option<String>,
}
#[derive(XmlSerialize, XmlRootTag, Debug)]
#[xml(root = b"push-message", ns = "rustical_dav::namespace::NS_DAVPUSH")]
#[xml(ns_prefix(
rustical_dav::namespace::NS_DAVPUSH = b"",
rustical_dav::namespace::NS_DAV = b"D",
))]
struct PushMessage {
#[xml(ns = "rustical_dav::namespace::NS_DAV")]
propstat: PropstatElement<PushMessageProp>,
}
// pub fn build_request(message: WebPushMessage) -> Request {
// // A little janky :)
// let url = reqwest::Url::from_str(&message.endpoint.to_string()).unwrap();
// let mut builder = Request::new(Method::POST, url);
//
// if let Some(topic) = message.topic {
// builder
// .headers_mut()
// .insert("Topic", HeaderValue::from_str(topic.as_str()).unwrap());
// }
//
// if let Some(payload) = message.payload {
// builder.headers_mut().insert(
// header::CONTENT_ENCODING,
// HeaderValue::from_static(payload.content_encoding.to_str()),
// );
// builder.headers_mut().insert(
// header::CONTENT_TYPE,
// HeaderValue::from_static("application/octet-stream"),
// );
//
// for (k, v) in payload.crypto_headers.into_iter() {
// let v: &str = v.as_ref();
// builder.headers_mut().insert(
// HeaderName::from_static(k),
// HeaderValue::from_str(&v).unwrap(),
// );
// }
//
// *builder.body_mut() = Some(reqwest::Body::from(payload.content));
// }
// builder
// }
pub async fn push_notifier(
allowed_push_servers: Option<Vec<String>>,
mut recv: Receiver<CollectionOperation>,
sub_store: Arc<impl SubscriptionStore>,
) {
let client = reqwest::Client::new();
while let Some(message) = recv.recv().await {
let subscribers = match sub_store.get_subscriptions(&message.topic).await {
Ok(subs) => subs,
Err(err) => {
error!("{err}");
continue;
}
};
let status = match message.r#type {
CollectionOperationType::Object => StatusCode::OK,
CollectionOperationType::Delete => StatusCode::NOT_FOUND,
};
let push_message = PushMessage {
propstat: PropstatElement {
prop: PushMessageProp {
topic: message.topic,
sync_token: message.sync_token,
},
status,
},
};
let mut output: Vec<_> = b"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n".into();
let mut writer = quick_xml::Writer::new_with_indent(&mut output, b' ', 4);
if let Err(err) = push_message.serialize_root(&mut writer) {
error!("Could not serialize push message: {}", err);
continue;
}
let payload = String::from_utf8(output).unwrap();
// for subscriber in subscribers {
// let push_resource = subscriber.push_resource;
//
// let sub_info = SubscriptionInfo {
// endpoint: push_resource.to_owned(),
// keys: web_push::SubscriptionKeys {
// p256dh: subscriber.public_key,
// auth: subscriber.auth_secret,
// },
// };
// let mut builder = WebPushMessageBuilder::new(&sub_info);
// builder.set_payload(web_push::ContentEncoding::Aes128Gcm, payload.as_bytes());
// let push_message = builder.build().unwrap();
// let request = build_request(push_message);
//
// let allowed = if let Some(allowed_push_servers) = &allowed_push_servers {
// if let Ok(resource_url) = reqwest::Url::parse(&push_resource) {
// let origin = resource_url.origin().ascii_serialization();
// allowed_push_servers
// .iter()
// .any(|allowed_push_server| allowed_push_server == &origin)
// } else {
// warn!("Invalid push url: {push_resource}");
// false
// }
// } else {
// true
// };
//
// if allowed {
// info!("Sending a push message to {}: {}", push_resource, payload);
// if let Err(err) = client.execute(request).await {
// error!("{err}");
// }
// } else {
// warn!(
// "Not sending a push notification to {} since it's not allowed in dav_push::allowed_push_servers",
// push_resource
// );
// }
// }
}
}

View File

@@ -7,6 +7,10 @@ repository.workspace = true
license.workspace = true
publish = false
[features]
default = []
dev = ["tower-http/fs"]
[dependencies]
tower.workspace = true
http.workspace = true
@@ -34,3 +38,4 @@ axum-extra.workspace = true
headers.workspace = true
tower-sessions.workspace = true
percent-encoding.workspace = true
tower-http = { workspace = true, optional = true }

View File

@@ -0,0 +1,19 @@
{
"tasks": {
"dev": "deno run -A --node-modules-dir npm:vite build --emptyOutDir --watch",
"build": "deno run -A --node-modules-dir npm:vite build --emptyOutDir"
},
"compilerOptions": {
"lib": [
"ES2024",
"DOM",
"DOM.Iterable"
]
},
"imports": {
"@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.4",
"lit": "npm:lit@^3.2.1",
"vite": "npm:vite@^6.1.1",
"webdav": "npm:webdav@^5.8.0"
}
}

436
crates/frontend/js-components/deno.lock generated Normal file
View File

@@ -0,0 +1,436 @@
{
"version": "4",
"specifiers": {
"npm:@deno/vite-plugin@^1.0.4": "1.0.4_vite@6.3.5__picomatch@4.0.2",
"npm:lit@^3.2.1": "3.3.0",
"npm:vite@*": "6.3.5_picomatch@4.0.2",
"npm:vite@^6.1.1": "6.3.5_picomatch@4.0.2",
"npm:webdav@^5.8.0": "5.8.0"
},
"npm": {
"@buttercup/fetch@0.2.1": {
"integrity": "sha512-sCgECOx8wiqY8NN1xN22BqqKzXYIG2AicNLlakOAI4f0WgyLVUbAigMf8CZhBtJxdudTcB1gD5lciqi44jwJvg==",
"dependencies": [
"node-fetch"
]
},
"@deno/vite-plugin@1.0.4_vite@6.3.5__picomatch@4.0.2": {
"integrity": "sha512-xg8YT8Wn2sGXSnJgiGTpBGX1Dov0c6fd1rAp8VsfrCUtyBRRWzwVMAnd3fQ4yq8h7LSVvJUxEFN4U421k/DQLA==",
"dependencies": [
"vite"
]
},
"@esbuild/aix-ppc64@0.25.5": {
"integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="
},
"@esbuild/android-arm64@0.25.5": {
"integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="
},
"@esbuild/android-arm@0.25.5": {
"integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="
},
"@esbuild/android-x64@0.25.5": {
"integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="
},
"@esbuild/darwin-arm64@0.25.5": {
"integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="
},
"@esbuild/darwin-x64@0.25.5": {
"integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="
},
"@esbuild/freebsd-arm64@0.25.5": {
"integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="
},
"@esbuild/freebsd-x64@0.25.5": {
"integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="
},
"@esbuild/linux-arm64@0.25.5": {
"integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="
},
"@esbuild/linux-arm@0.25.5": {
"integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="
},
"@esbuild/linux-ia32@0.25.5": {
"integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="
},
"@esbuild/linux-loong64@0.25.5": {
"integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="
},
"@esbuild/linux-mips64el@0.25.5": {
"integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="
},
"@esbuild/linux-ppc64@0.25.5": {
"integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="
},
"@esbuild/linux-riscv64@0.25.5": {
"integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="
},
"@esbuild/linux-s390x@0.25.5": {
"integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="
},
"@esbuild/linux-x64@0.25.5": {
"integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="
},
"@esbuild/netbsd-arm64@0.25.5": {
"integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="
},
"@esbuild/netbsd-x64@0.25.5": {
"integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="
},
"@esbuild/openbsd-arm64@0.25.5": {
"integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="
},
"@esbuild/openbsd-x64@0.25.5": {
"integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="
},
"@esbuild/sunos-x64@0.25.5": {
"integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="
},
"@esbuild/win32-arm64@0.25.5": {
"integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="
},
"@esbuild/win32-ia32@0.25.5": {
"integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="
},
"@esbuild/win32-x64@0.25.5": {
"integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="
},
"@lit-labs/ssr-dom-shim@1.3.0": {
"integrity": "sha512-nQIWonJ6eFAvUUrSlwyHDm/aE8PBDu5kRpL0vHMg6K8fK3Diq1xdPjTnsJSwxABhaZ+5eBi1btQB5ShUTKo4nQ=="
},
"@lit/reactive-element@2.1.0": {
"integrity": "sha512-L2qyoZSQClcBmq0qajBVbhYEcG6iK0XfLn66ifLe/RfC0/ihpc+pl0Wdn8bJ8o+hj38cG0fGXRgSS20MuXn7qA==",
"dependencies": [
"@lit-labs/ssr-dom-shim"
]
},
"@rollup/rollup-android-arm-eabi@4.43.0": {
"integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw=="
},
"@rollup/rollup-android-arm64@4.43.0": {
"integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA=="
},
"@rollup/rollup-darwin-arm64@4.43.0": {
"integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A=="
},
"@rollup/rollup-darwin-x64@4.43.0": {
"integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg=="
},
"@rollup/rollup-freebsd-arm64@4.43.0": {
"integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ=="
},
"@rollup/rollup-freebsd-x64@4.43.0": {
"integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg=="
},
"@rollup/rollup-linux-arm-gnueabihf@4.43.0": {
"integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw=="
},
"@rollup/rollup-linux-arm-musleabihf@4.43.0": {
"integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw=="
},
"@rollup/rollup-linux-arm64-gnu@4.43.0": {
"integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA=="
},
"@rollup/rollup-linux-arm64-musl@4.43.0": {
"integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA=="
},
"@rollup/rollup-linux-loongarch64-gnu@4.43.0": {
"integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg=="
},
"@rollup/rollup-linux-powerpc64le-gnu@4.43.0": {
"integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw=="
},
"@rollup/rollup-linux-riscv64-gnu@4.43.0": {
"integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g=="
},
"@rollup/rollup-linux-riscv64-musl@4.43.0": {
"integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q=="
},
"@rollup/rollup-linux-s390x-gnu@4.43.0": {
"integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="
},
"@rollup/rollup-linux-x64-gnu@4.43.0": {
"integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="
},
"@rollup/rollup-linux-x64-musl@4.43.0": {
"integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="
},
"@rollup/rollup-win32-arm64-msvc@4.43.0": {
"integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw=="
},
"@rollup/rollup-win32-ia32-msvc@4.43.0": {
"integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw=="
},
"@rollup/rollup-win32-x64-msvc@4.43.0": {
"integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw=="
},
"@types/estree@1.0.7": {
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
},
"@types/trusted-types@2.0.7": {
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"balanced-match@1.0.2": {
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"base-64@1.0.0": {
"integrity": "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="
},
"brace-expansion@2.0.2": {
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dependencies": [
"balanced-match"
]
},
"byte-length@1.0.2": {
"integrity": "sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q=="
},
"charenc@0.0.2": {
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
},
"crypt@0.0.2": {
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
},
"data-uri-to-buffer@4.0.1": {
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="
},
"entities@6.0.1": {
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
},
"esbuild@0.25.5": {
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"dependencies": [
"@esbuild/aix-ppc64",
"@esbuild/android-arm",
"@esbuild/android-arm64",
"@esbuild/android-x64",
"@esbuild/darwin-arm64",
"@esbuild/darwin-x64",
"@esbuild/freebsd-arm64",
"@esbuild/freebsd-x64",
"@esbuild/linux-arm",
"@esbuild/linux-arm64",
"@esbuild/linux-ia32",
"@esbuild/linux-loong64",
"@esbuild/linux-mips64el",
"@esbuild/linux-ppc64",
"@esbuild/linux-riscv64",
"@esbuild/linux-s390x",
"@esbuild/linux-x64",
"@esbuild/netbsd-arm64",
"@esbuild/netbsd-x64",
"@esbuild/openbsd-arm64",
"@esbuild/openbsd-x64",
"@esbuild/sunos-x64",
"@esbuild/win32-arm64",
"@esbuild/win32-ia32",
"@esbuild/win32-x64"
]
},
"fast-xml-parser@4.5.3": {
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"dependencies": [
"strnum"
]
},
"fdir@6.4.6_picomatch@4.0.2": {
"integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==",
"dependencies": [
"picomatch"
]
},
"fetch-blob@3.2.0": {
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"dependencies": [
"node-domexception",
"web-streams-polyfill"
]
},
"formdata-polyfill@4.0.10": {
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": [
"fetch-blob"
]
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="
},
"hot-patcher@2.0.1": {
"integrity": "sha512-ECg1JFG0YzehicQaogenlcs2qg6WsXQsxtnbr1i696u5tLUjtJdQAh0u2g0Q5YV45f263Ta1GnUJsc8WIfJf4Q=="
},
"is-buffer@1.1.6": {
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"layerr@3.0.0": {
"integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA=="
},
"lit-element@4.2.0": {
"integrity": "sha512-MGrXJVAI5x+Bfth/pU9Kst1iWID6GHDLEzFEnyULB/sFiRLgkd8NPK/PeeXxktA3T6EIIaq8U3KcbTU5XFcP2Q==",
"dependencies": [
"@lit-labs/ssr-dom-shim",
"@lit/reactive-element",
"lit-html"
]
},
"lit-html@3.3.0": {
"integrity": "sha512-RHoswrFAxY2d8Cf2mm4OZ1DgzCoBKUKSPvA1fhtSELxUERq2aQQ2h05pO9j81gS1o7RIRJ+CePLogfyahwmynw==",
"dependencies": [
"@types/trusted-types"
]
},
"lit@3.3.0": {
"integrity": "sha512-DGVsqsOIHBww2DqnuZzW7QsuCdahp50ojuDaBPC7jUDRpYoH0z7kHBBYZewRzer75FwtrkmkKk7iOAwSaWdBmw==",
"dependencies": [
"@lit/reactive-element",
"lit-element",
"lit-html"
]
},
"md5@2.3.0": {
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": [
"charenc",
"crypt",
"is-buffer"
]
},
"minimatch@9.0.5": {
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dependencies": [
"brace-expansion"
]
},
"nanoid@3.3.11": {
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
},
"nested-property@4.0.0": {
"integrity": "sha512-yFehXNWRs4cM0+dz7QxCd06hTbWbSkV0ISsqBfkntU6TOY4Qm3Q88fRRLOddkGh2Qq6dZvnKVAahfhjcUvLnyA=="
},
"node-domexception@1.0.0": {
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="
},
"node-fetch@3.3.2": {
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": [
"data-uri-to-buffer",
"fetch-blob",
"formdata-polyfill"
]
},
"path-posix@1.0.0": {
"integrity": "sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA=="
},
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@4.0.2": {
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="
},
"postcss@8.5.5": {
"integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==",
"dependencies": [
"nanoid",
"picocolors",
"source-map-js"
]
},
"querystringify@2.2.0": {
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"requires-port@1.0.0": {
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
},
"rollup@4.43.0": {
"integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==",
"dependencies": [
"@rollup/rollup-android-arm-eabi",
"@rollup/rollup-android-arm64",
"@rollup/rollup-darwin-arm64",
"@rollup/rollup-darwin-x64",
"@rollup/rollup-freebsd-arm64",
"@rollup/rollup-freebsd-x64",
"@rollup/rollup-linux-arm-gnueabihf",
"@rollup/rollup-linux-arm-musleabihf",
"@rollup/rollup-linux-arm64-gnu",
"@rollup/rollup-linux-arm64-musl",
"@rollup/rollup-linux-loongarch64-gnu",
"@rollup/rollup-linux-powerpc64le-gnu",
"@rollup/rollup-linux-riscv64-gnu",
"@rollup/rollup-linux-riscv64-musl",
"@rollup/rollup-linux-s390x-gnu",
"@rollup/rollup-linux-x64-gnu",
"@rollup/rollup-linux-x64-musl",
"@rollup/rollup-win32-arm64-msvc",
"@rollup/rollup-win32-ia32-msvc",
"@rollup/rollup-win32-x64-msvc",
"@types/estree",
"fsevents"
]
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
"strnum@1.1.2": {
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="
},
"tinyglobby@0.2.14_picomatch@4.0.2": {
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"dependencies": [
"fdir",
"picomatch"
]
},
"url-join@5.0.0": {
"integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="
},
"url-parse@1.5.10": {
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": [
"querystringify",
"requires-port"
]
},
"vite@6.3.5_picomatch@4.0.2": {
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
"dependencies": [
"esbuild",
"fdir",
"fsevents",
"picomatch",
"postcss",
"rollup",
"tinyglobby"
]
},
"web-streams-polyfill@3.3.3": {
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="
},
"webdav@5.8.0": {
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
"dependencies": [
"@buttercup/fetch",
"base-64",
"byte-length",
"entities",
"fast-xml-parser",
"hot-patcher",
"layerr",
"md5",
"minimatch",
"nested-property",
"node-fetch",
"path-posix",
"url-join",
"url-parse"
]
}
},
"workspace": {
"dependencies": [
"npm:@deno/vite-plugin@^1.0.4",
"npm:lit@^3.2.1",
"npm:vite@^6.1.1",
"npm:webdav@^5.8.0"
]
}
}

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Lit</title>
<link rel="stylesheet" href="./src/index.css" />
<script type="module" src="/src/my-element.ts"></script>
</head>
<body>
<my-element>
<h1>Vite + Lit</h1>
</my-element>
</body>
</html>

View File

@@ -0,0 +1,92 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
@customElement("create-addressbook-form")
export class CreateAddressbookForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
client = createClient("/carddav")
@property()
user: String = ''
@property()
addr_id: String = ''
@property()
displayname: String = ''
@property()
description: String = ''
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Create addressbook</button>
<dialog ${ref(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
id
<input type="text" name="id" @change=${e => this.addr_id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
</label>
<br>
<button type="submit">Create</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
console.log(this.displayname)
e.preventDefault()
if (!this.addr_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
// TODO: Escape user input: There's not really a security risk here but would be nicer
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ''}
</prop>
</set>
</mkcol>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'create-addressbook-form': CreateAddressbookForm
}
}

View File

@@ -0,0 +1,123 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { Ref, createRef, ref } from 'lit/directives/ref.js';
import { createClient } from "webdav";
@customElement("create-calendar-form")
export class CreateCalendarForm extends LitElement {
constructor() {
super()
}
protected override createRenderRoot() {
return this
}
client = createClient("/caldav")
@property()
user: String = ''
@property()
cal_id: String = ''
@property()
displayname: String = ''
@property()
description: String = ''
@property()
color: String = ''
@property()
subscriptionUrl: String = ''
@property()
components: Set<"VEVENT" | "VTODO" | "VJOURNAL"> = new Set()
dialog: Ref<HTMLDialogElement> = createRef()
form: Ref<HTMLFormElement> = createRef()
override render() {
return html`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button>
<dialog ${ref(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${ref(this.form)}>
<label>
id
<input type="text" name="id" @change=${e => this.cal_id = e.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${e => this.displayname = e.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${e => this.description = e.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" @change=${e => this.color = e.target.value} />
</label>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${e => this.subscriptionUrl = e.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map(comp => html`
<label>
Support ${comp}
<input type="checkbox" value=${comp} @change=${e => e.target.checked ? this.components.add(e.target.value) : this.components.delete(e.target.value)} />
</label>
`)}
<br>
<button type="submit">Create</button>
<button type="submit" @click=${event => { event.preventDefault(); this.dialog.value.close(); this.form.value.reset() }} class="cancel">Cancel</button>
</form>
</dialog>
`
}
async submit(e: SubmitEvent) {
console.log(this.displayname)
e.preventDefault()
if (!this.cal_id) {
alert("Empty id")
return
}
if (!this.displayname) {
alert("Empty displayname")
return
}
if (!this.components.size) {
alert("No calendar components selected")
return
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ''}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ''}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ''}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map(comp => `<CAL:comp name="${comp}" />`).join('\n')}
</CAL:supported-calendar-component-set>
</prop>
</set>
</mkcol>
`
})
window.location.reload()
return null
}
}
declare global {
interface HTMLElementTagNameMap {
'create-calendar-form': CreateCalendarForm
}
}

View File

@@ -0,0 +1,44 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { createClient } from "webdav";
@customElement("delete-button")
export class DeleteButton extends LitElement {
constructor() {
super()
}
@property({ type: Boolean })
trash: boolean = false
@property()
href: string
protected createRenderRoot() {
return this
}
protected render() {
let text = this.trash ? 'Move to trash' : 'Delete'
return html`<button class="delete" @click=${e => this._onClick(e)}>${text}</button>`
}
async _onClick(event: Event) {
event.preventDefault()
if (!this.trash && !confirm('Do you want to delete this collection permanently?')) {
return
}
let response = await fetch(this.href, {
method: 'DELETE',
headers: {
'X-No-Trashbin': this.trash ? '0' : '1'
}
})
if (response.status < 200 || response.status >= 300) {
alert('An error occured, look into the console')
console.error(response)
return
}
window.location.reload()
}
}

View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es2024",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"lib": [
"dom",
"es2024"
]
},
"include": [
"lib/**/*.ts"
]
}

View File

@@ -0,0 +1,31 @@
import { defineConfig } from 'vite'
export default defineConfig({
build: {
minify: false,
modulePreload: {
polyfill: false
},
copyPublicDir: false,
lib: {
entry: 'lib/index.ts',
formats: ['es'],
},
rollupOptions: {
input: [
"lib/create-calendar-form.ts",
"lib/create-addressbook-form.ts",
"lib/delete-button.ts",
],
output: {
dir: "../public/assets/js/",
format: "es",
manualChunks: {
lit: ["lit"],
webdav: ["webdav"],
}
}
},
},
})

View File

@@ -0,0 +1,104 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let CreateAddressbookForm = class extends i {
constructor() {
super();
this.client = an("/carddav");
this.user = "";
this.addr_id = "";
this.displayname = "";
this.description = "";
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Create addressbook</button>
<dialog ${n(this.dialog)}>
<h3>Create addressbook</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
id
<input type="text" name="id" @change=${(e2) => this.addr_id = e2.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} />
</label>
<br>
<button type="submit">Create</button>
<button type="submit" @click=${(event) => {
event.preventDefault();
this.dialog.value.close();
this.form.value.reset();
}} class="cancel">Cancel</button>
</form>
</dialog>
`;
}
async submit(e2) {
console.log(this.displayname);
e2.preventDefault();
if (!this.addr_id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.addr_id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CARD:addressbook-description>${this.description}</CARD:addressbook-description>` : ""}
</prop>
</set>
</mkcol>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "addr_id", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], CreateAddressbookForm.prototype, "description", 2);
CreateAddressbookForm = __decorateClass([
t("create-addressbook-form")
], CreateAddressbookForm);
export {
CreateAddressbookForm
};

View File

@@ -0,0 +1,142 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n as n$1, t } from "./property-D0NJdseG.mjs";
import { e, n } from "./ref-CPp9J0V5.mjs";
import { a as an } from "./webdav-D0R7xCzX.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let CreateCalendarForm = class extends i {
constructor() {
super();
this.client = an("/caldav");
this.user = "";
this.cal_id = "";
this.displayname = "";
this.description = "";
this.color = "";
this.subscriptionUrl = "";
this.components = /* @__PURE__ */ new Set();
this.dialog = e();
this.form = e();
}
createRenderRoot() {
return this;
}
render() {
return x`
<button @click=${() => this.dialog.value.showModal()}>Create calendar</button>
<dialog ${n(this.dialog)}>
<h3>Create calendar</h3>
<form @submit=${this.submit} ${n(this.form)}>
<label>
id
<input type="text" name="id" @change=${(e2) => this.cal_id = e2.target.value} />
</label>
<br>
<label>
Displayname
<input type="text" name="displayname" value=${this.displayname} @change=${(e2) => this.displayname = e2.target.value} />
</label>
<br>
<label>
Description
<input type="text" name="description" @change=${(e2) => this.description = e2.target.value} />
</label>
<br>
<label>
Color
<input type="color" name="color" @change=${(e2) => this.color = e2.target.value} />
</label>
<br>
<label>
Subscription URL
<input type="text" name="subscription_url" @change=${(e2) => this.subscriptionUrl = e2.target.value} />
</label>
<br>
${["VEVENT", "VTODO", "VJOURNAL"].map((comp) => x`
<label>
Support ${comp}
<input type="checkbox" value=${comp} @change=${(e2) => e2.target.checked ? this.components.add(e2.target.value) : this.components.delete(e2.target.value)} />
</label>
`)}
<br>
<button type="submit">Create</button>
<button type="submit" @click=${(event) => {
event.preventDefault();
this.dialog.value.close();
this.form.value.reset();
}} class="cancel">Cancel</button>
</form>
</dialog>
`;
}
async submit(e2) {
console.log(this.displayname);
e2.preventDefault();
if (!this.cal_id) {
alert("Empty id");
return;
}
if (!this.displayname) {
alert("Empty displayname");
return;
}
if (!this.components.size) {
alert("No calendar components selected");
return;
}
await this.client.createDirectory(`/principal/${this.user}/${this.cal_id}`, {
data: `
<mkcol xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/" xmlns:ICAL="http://apple.com/ns/ical/">
<set>
<prop>
<displayname>${this.displayname}</displayname>
${this.description ? `<CAL:calendar-description>${this.description}</CAL:calendar-description>` : ""}
${this.color ? `<ICAL:calendar-color>${this.color}</ICAL:calendar-color>` : ""}
${this.subscriptionUrl ? `<CS:source><href>${this.subscriptionUrl}</href></CS:source>` : ""}
<CAL:supported-calendar-component-set>
${Array.from(this.components.keys()).map((comp) => `<CAL:comp name="${comp}" />`).join("\n")}
</CAL:supported-calendar-component-set>
</prop>
</set>
</mkcol>
`
});
window.location.reload();
return null;
}
};
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "user", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "cal_id", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "displayname", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "description", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "color", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "subscriptionUrl", 2);
__decorateClass([
n$1()
], CreateCalendarForm.prototype, "components", 2);
CreateCalendarForm = __decorateClass([
t("create-calendar-form")
], CreateCalendarForm);
export {
CreateCalendarForm
};

View File

@@ -0,0 +1,55 @@
import { i, x } from "./lit-z6_uA4GX.mjs";
import { n, t } from "./property-D0NJdseG.mjs";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __decorateClass = (decorators, target, key, kind) => {
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
for (var i2 = decorators.length - 1, decorator; i2 >= 0; i2--)
if (decorator = decorators[i2])
result = (kind ? decorator(target, key, result) : decorator(result)) || result;
if (kind && result) __defProp(target, key, result);
return result;
};
let DeleteButton = class extends i {
constructor() {
super();
this.trash = false;
}
createRenderRoot() {
return this;
}
render() {
let text = this.trash ? "Move to trash" : "Delete";
return x`<button class="delete" @click=${(e) => this._onClick(e)}>${text}</button>`;
}
async _onClick(event) {
event.preventDefault();
if (!this.trash && !confirm("Do you want to delete this collection permanently?")) {
return;
}
let response = await fetch(this.href, {
method: "DELETE",
headers: {
"X-No-Trashbin": this.trash ? "0" : "1"
}
});
if (response.status < 200 || response.status >= 300) {
alert("An error occured, look into the console");
console.error(response);
return;
}
window.location.reload();
}
};
__decorateClass([
n({ type: Boolean })
], DeleteButton.prototype, "trash", 2);
__decorateClass([
n()
], DeleteButton.prototype, "href", 2);
DeleteButton = __decorateClass([
t("delete-button")
], DeleteButton);
export {
DeleteButton
};

View File

@@ -0,0 +1,550 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
var _a;
const t$1 = globalThis, e$2 = t$1.ShadowRoot && (void 0 === t$1.ShadyCSS || t$1.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, s$2 = Symbol(), o$3 = /* @__PURE__ */ new WeakMap();
let n$2 = class n {
constructor(t2, e2, o2) {
if (this._$cssResult$ = true, o2 !== s$2) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");
this.cssText = t2, this.t = e2;
}
get styleSheet() {
let t2 = this.o;
const s2 = this.t;
if (e$2 && void 0 === t2) {
const e2 = void 0 !== s2 && 1 === s2.length;
e2 && (t2 = o$3.get(s2)), void 0 === t2 && ((this.o = t2 = new CSSStyleSheet()).replaceSync(this.cssText), e2 && o$3.set(s2, t2));
}
return t2;
}
toString() {
return this.cssText;
}
};
const r$2 = (t2) => new n$2("string" == typeof t2 ? t2 : t2 + "", void 0, s$2), S$1 = (s2, o2) => {
if (e$2) s2.adoptedStyleSheets = o2.map((t2) => t2 instanceof CSSStyleSheet ? t2 : t2.styleSheet);
else for (const e2 of o2) {
const o3 = document.createElement("style"), n3 = t$1.litNonce;
void 0 !== n3 && o3.setAttribute("nonce", n3), o3.textContent = e2.cssText, s2.appendChild(o3);
}
}, c$2 = e$2 ? (t2) => t2 : (t2) => t2 instanceof CSSStyleSheet ? ((t3) => {
let e2 = "";
for (const s2 of t3.cssRules) e2 += s2.cssText;
return r$2(e2);
})(t2) : t2;
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const { is: i$2, defineProperty: e$1, getOwnPropertyDescriptor: h$1, getOwnPropertyNames: r$1, getOwnPropertySymbols: o$2, getPrototypeOf: n$1 } = Object, a$1 = globalThis, c$1 = a$1.trustedTypes, l$1 = c$1 ? c$1.emptyScript : "", p$1 = a$1.reactiveElementPolyfillSupport, d$1 = (t2, s2) => t2, u$1 = { toAttribute(t2, s2) {
switch (s2) {
case Boolean:
t2 = t2 ? l$1 : null;
break;
case Object:
case Array:
t2 = null == t2 ? t2 : JSON.stringify(t2);
}
return t2;
}, fromAttribute(t2, s2) {
let i2 = t2;
switch (s2) {
case Boolean:
i2 = null !== t2;
break;
case Number:
i2 = null === t2 ? null : Number(t2);
break;
case Object:
case Array:
try {
i2 = JSON.parse(t2);
} catch (t3) {
i2 = null;
}
}
return i2;
} }, f$1 = (t2, s2) => !i$2(t2, s2), b = { attribute: true, type: String, converter: u$1, reflect: false, useDefault: false, hasChanged: f$1 };
Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), a$1.litPropertyMetadata ?? (a$1.litPropertyMetadata = /* @__PURE__ */ new WeakMap());
let y$1 = class y extends HTMLElement {
static addInitializer(t2) {
this._$Ei(), (this.l ?? (this.l = [])).push(t2);
}
static get observedAttributes() {
return this.finalize(), this._$Eh && [...this._$Eh.keys()];
}
static createProperty(t2, s2 = b) {
if (s2.state && (s2.attribute = false), this._$Ei(), this.prototype.hasOwnProperty(t2) && ((s2 = Object.create(s2)).wrapped = true), this.elementProperties.set(t2, s2), !s2.noAccessor) {
const i2 = Symbol(), h2 = this.getPropertyDescriptor(t2, i2, s2);
void 0 !== h2 && e$1(this.prototype, t2, h2);
}
}
static getPropertyDescriptor(t2, s2, i2) {
const { get: e2, set: r2 } = h$1(this.prototype, t2) ?? { get() {
return this[s2];
}, set(t3) {
this[s2] = t3;
} };
return { get: e2, set(s3) {
const h2 = e2 == null ? void 0 : e2.call(this);
r2 == null ? void 0 : r2.call(this, s3), this.requestUpdate(t2, h2, i2);
}, configurable: true, enumerable: true };
}
static getPropertyOptions(t2) {
return this.elementProperties.get(t2) ?? b;
}
static _$Ei() {
if (this.hasOwnProperty(d$1("elementProperties"))) return;
const t2 = n$1(this);
t2.finalize(), void 0 !== t2.l && (this.l = [...t2.l]), this.elementProperties = new Map(t2.elementProperties);
}
static finalize() {
if (this.hasOwnProperty(d$1("finalized"))) return;
if (this.finalized = true, this._$Ei(), this.hasOwnProperty(d$1("properties"))) {
const t3 = this.properties, s2 = [...r$1(t3), ...o$2(t3)];
for (const i2 of s2) this.createProperty(i2, t3[i2]);
}
const t2 = this[Symbol.metadata];
if (null !== t2) {
const s2 = litPropertyMetadata.get(t2);
if (void 0 !== s2) for (const [t3, i2] of s2) this.elementProperties.set(t3, i2);
}
this._$Eh = /* @__PURE__ */ new Map();
for (const [t3, s2] of this.elementProperties) {
const i2 = this._$Eu(t3, s2);
void 0 !== i2 && this._$Eh.set(i2, t3);
}
this.elementStyles = this.finalizeStyles(this.styles);
}
static finalizeStyles(s2) {
const i2 = [];
if (Array.isArray(s2)) {
const e2 = new Set(s2.flat(1 / 0).reverse());
for (const s3 of e2) i2.unshift(c$2(s3));
} else void 0 !== s2 && i2.push(c$2(s2));
return i2;
}
static _$Eu(t2, s2) {
const i2 = s2.attribute;
return false === i2 ? void 0 : "string" == typeof i2 ? i2 : "string" == typeof t2 ? t2.toLowerCase() : void 0;
}
constructor() {
super(), this._$Ep = void 0, this.isUpdatePending = false, this.hasUpdated = false, this._$Em = null, this._$Ev();
}
_$Ev() {
var _a2;
this._$ES = new Promise((t2) => this.enableUpdating = t2), this._$AL = /* @__PURE__ */ new Map(), this._$E_(), this.requestUpdate(), (_a2 = this.constructor.l) == null ? void 0 : _a2.forEach((t2) => t2(this));
}
addController(t2) {
var _a2;
(this._$EO ?? (this._$EO = /* @__PURE__ */ new Set())).add(t2), void 0 !== this.renderRoot && this.isConnected && ((_a2 = t2.hostConnected) == null ? void 0 : _a2.call(t2));
}
removeController(t2) {
var _a2;
(_a2 = this._$EO) == null ? void 0 : _a2.delete(t2);
}
_$E_() {
const t2 = /* @__PURE__ */ new Map(), s2 = this.constructor.elementProperties;
for (const i2 of s2.keys()) this.hasOwnProperty(i2) && (t2.set(i2, this[i2]), delete this[i2]);
t2.size > 0 && (this._$Ep = t2);
}
createRenderRoot() {
const t2 = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions);
return S$1(t2, this.constructor.elementStyles), t2;
}
connectedCallback() {
var _a2;
this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this.enableUpdating(true), (_a2 = this._$EO) == null ? void 0 : _a2.forEach((t2) => {
var _a3;
return (_a3 = t2.hostConnected) == null ? void 0 : _a3.call(t2);
});
}
enableUpdating(t2) {
}
disconnectedCallback() {
var _a2;
(_a2 = this._$EO) == null ? void 0 : _a2.forEach((t2) => {
var _a3;
return (_a3 = t2.hostDisconnected) == null ? void 0 : _a3.call(t2);
});
}
attributeChangedCallback(t2, s2, i2) {
this._$AK(t2, i2);
}
_$ET(t2, s2) {
var _a2;
const i2 = this.constructor.elementProperties.get(t2), e2 = this.constructor._$Eu(t2, i2);
if (void 0 !== e2 && true === i2.reflect) {
const h2 = (void 0 !== ((_a2 = i2.converter) == null ? void 0 : _a2.toAttribute) ? i2.converter : u$1).toAttribute(s2, i2.type);
this._$Em = t2, null == h2 ? this.removeAttribute(e2) : this.setAttribute(e2, h2), this._$Em = null;
}
}
_$AK(t2, s2) {
var _a2, _b;
const i2 = this.constructor, e2 = i2._$Eh.get(t2);
if (void 0 !== e2 && this._$Em !== e2) {
const t3 = i2.getPropertyOptions(e2), h2 = "function" == typeof t3.converter ? { fromAttribute: t3.converter } : void 0 !== ((_a2 = t3.converter) == null ? void 0 : _a2.fromAttribute) ? t3.converter : u$1;
this._$Em = e2, this[e2] = h2.fromAttribute(s2, t3.type) ?? ((_b = this._$Ej) == null ? void 0 : _b.get(e2)) ?? null, this._$Em = null;
}
}
requestUpdate(t2, s2, i2) {
var _a2;
if (void 0 !== t2) {
const e2 = this.constructor, h2 = this[t2];
if (i2 ?? (i2 = e2.getPropertyOptions(t2)), !((i2.hasChanged ?? f$1)(h2, s2) || i2.useDefault && i2.reflect && h2 === ((_a2 = this._$Ej) == null ? void 0 : _a2.get(t2)) && !this.hasAttribute(e2._$Eu(t2, i2)))) return;
this.C(t2, s2, i2);
}
false === this.isUpdatePending && (this._$ES = this._$EP());
}
C(t2, s2, { useDefault: i2, reflect: e2, wrapped: h2 }, r2) {
i2 && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(t2) && (this._$Ej.set(t2, r2 ?? s2 ?? this[t2]), true !== h2 || void 0 !== r2) || (this._$AL.has(t2) || (this.hasUpdated || i2 || (s2 = void 0), this._$AL.set(t2, s2)), true === e2 && this._$Em !== t2 && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(t2));
}
async _$EP() {
this.isUpdatePending = true;
try {
await this._$ES;
} catch (t3) {
Promise.reject(t3);
}
const t2 = this.scheduleUpdate();
return null != t2 && await t2, !this.isUpdatePending;
}
scheduleUpdate() {
return this.performUpdate();
}
performUpdate() {
var _a2;
if (!this.isUpdatePending) return;
if (!this.hasUpdated) {
if (this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this._$Ep) {
for (const [t4, s3] of this._$Ep) this[t4] = s3;
this._$Ep = void 0;
}
const t3 = this.constructor.elementProperties;
if (t3.size > 0) for (const [s3, i2] of t3) {
const { wrapped: t4 } = i2, e2 = this[s3];
true !== t4 || this._$AL.has(s3) || void 0 === e2 || this.C(s3, void 0, i2, e2);
}
}
let t2 = false;
const s2 = this._$AL;
try {
t2 = this.shouldUpdate(s2), t2 ? (this.willUpdate(s2), (_a2 = this._$EO) == null ? void 0 : _a2.forEach((t3) => {
var _a3;
return (_a3 = t3.hostUpdate) == null ? void 0 : _a3.call(t3);
}), this.update(s2)) : this._$EM();
} catch (s3) {
throw t2 = false, this._$EM(), s3;
}
t2 && this._$AE(s2);
}
willUpdate(t2) {
}
_$AE(t2) {
var _a2;
(_a2 = this._$EO) == null ? void 0 : _a2.forEach((t3) => {
var _a3;
return (_a3 = t3.hostUpdated) == null ? void 0 : _a3.call(t3);
}), this.hasUpdated || (this.hasUpdated = true, this.firstUpdated(t2)), this.updated(t2);
}
_$EM() {
this._$AL = /* @__PURE__ */ new Map(), this.isUpdatePending = false;
}
get updateComplete() {
return this.getUpdateComplete();
}
getUpdateComplete() {
return this._$ES;
}
shouldUpdate(t2) {
return true;
}
update(t2) {
this._$Eq && (this._$Eq = this._$Eq.forEach((t3) => this._$ET(t3, this[t3]))), this._$EM();
}
updated(t2) {
}
firstUpdated(t2) {
}
};
y$1.elementStyles = [], y$1.shadowRootOptions = { mode: "open" }, y$1[d$1("elementProperties")] = /* @__PURE__ */ new Map(), y$1[d$1("finalized")] = /* @__PURE__ */ new Map(), p$1 == null ? void 0 : p$1({ ReactiveElement: y$1 }), (a$1.reactiveElementVersions ?? (a$1.reactiveElementVersions = [])).push("2.1.0");
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const t = globalThis, i$1 = t.trustedTypes, s$1 = i$1 ? i$1.createPolicy("lit-html", { createHTML: (t2) => t2 }) : void 0, e = "$lit$", h = `lit$${Math.random().toFixed(9).slice(2)}$`, o$1 = "?" + h, n2 = `<${o$1}>`, r = document, l = () => r.createComment(""), c = (t2) => null === t2 || "object" != typeof t2 && "function" != typeof t2, a = Array.isArray, u = (t2) => a(t2) || "function" == typeof (t2 == null ? void 0 : t2[Symbol.iterator]), d = "[ \n\f\r]", f = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, v = /-->/g, _ = />/g, m = RegExp(`>|${d}(?:([^\\s"'>=/]+)(${d}*=${d}*(?:[^
\f\r"'\`<>=]|("|')|))|$)`, "g"), p = /'/g, g = /"/g, $ = /^(?:script|style|textarea|title)$/i, y2 = (t2) => (i2, ...s2) => ({ _$litType$: t2, strings: i2, values: s2 }), x = y2(1), T = Symbol.for("lit-noChange"), E = Symbol.for("lit-nothing"), A = /* @__PURE__ */ new WeakMap(), C = r.createTreeWalker(r, 129);
function P(t2, i2) {
if (!a(t2) || !t2.hasOwnProperty("raw")) throw Error("invalid template strings array");
return void 0 !== s$1 ? s$1.createHTML(i2) : i2;
}
const V = (t2, i2) => {
const s2 = t2.length - 1, o2 = [];
let r2, l2 = 2 === i2 ? "<svg>" : 3 === i2 ? "<math>" : "", c2 = f;
for (let i3 = 0; i3 < s2; i3++) {
const s3 = t2[i3];
let a2, u2, d2 = -1, y3 = 0;
for (; y3 < s3.length && (c2.lastIndex = y3, u2 = c2.exec(s3), null !== u2); ) y3 = c2.lastIndex, c2 === f ? "!--" === u2[1] ? c2 = v : void 0 !== u2[1] ? c2 = _ : void 0 !== u2[2] ? ($.test(u2[2]) && (r2 = RegExp("</" + u2[2], "g")), c2 = m) : void 0 !== u2[3] && (c2 = m) : c2 === m ? ">" === u2[0] ? (c2 = r2 ?? f, d2 = -1) : void 0 === u2[1] ? d2 = -2 : (d2 = c2.lastIndex - u2[2].length, a2 = u2[1], c2 = void 0 === u2[3] ? m : '"' === u2[3] ? g : p) : c2 === g || c2 === p ? c2 = m : c2 === v || c2 === _ ? c2 = f : (c2 = m, r2 = void 0);
const x2 = c2 === m && t2[i3 + 1].startsWith("/>") ? " " : "";
l2 += c2 === f ? s3 + n2 : d2 >= 0 ? (o2.push(a2), s3.slice(0, d2) + e + s3.slice(d2) + h + x2) : s3 + h + (-2 === d2 ? i3 : x2);
}
return [P(t2, l2 + (t2[s2] || "<?>") + (2 === i2 ? "</svg>" : 3 === i2 ? "</math>" : "")), o2];
};
class N {
constructor({ strings: t2, _$litType$: s2 }, n3) {
let r2;
this.parts = [];
let c2 = 0, a2 = 0;
const u2 = t2.length - 1, d2 = this.parts, [f2, v2] = V(t2, s2);
if (this.el = N.createElement(f2, n3), C.currentNode = this.el.content, 2 === s2 || 3 === s2) {
const t3 = this.el.content.firstChild;
t3.replaceWith(...t3.childNodes);
}
for (; null !== (r2 = C.nextNode()) && d2.length < u2; ) {
if (1 === r2.nodeType) {
if (r2.hasAttributes()) for (const t3 of r2.getAttributeNames()) if (t3.endsWith(e)) {
const i2 = v2[a2++], s3 = r2.getAttribute(t3).split(h), e2 = /([.?@])?(.*)/.exec(i2);
d2.push({ type: 1, index: c2, name: e2[2], strings: s3, ctor: "." === e2[1] ? H : "?" === e2[1] ? I : "@" === e2[1] ? L : k }), r2.removeAttribute(t3);
} else t3.startsWith(h) && (d2.push({ type: 6, index: c2 }), r2.removeAttribute(t3));
if ($.test(r2.tagName)) {
const t3 = r2.textContent.split(h), s3 = t3.length - 1;
if (s3 > 0) {
r2.textContent = i$1 ? i$1.emptyScript : "";
for (let i2 = 0; i2 < s3; i2++) r2.append(t3[i2], l()), C.nextNode(), d2.push({ type: 2, index: ++c2 });
r2.append(t3[s3], l());
}
}
} else if (8 === r2.nodeType) if (r2.data === o$1) d2.push({ type: 2, index: c2 });
else {
let t3 = -1;
for (; -1 !== (t3 = r2.data.indexOf(h, t3 + 1)); ) d2.push({ type: 7, index: c2 }), t3 += h.length - 1;
}
c2++;
}
}
static createElement(t2, i2) {
const s2 = r.createElement("template");
return s2.innerHTML = t2, s2;
}
}
function S(t2, i2, s2 = t2, e2) {
var _a2, _b;
if (i2 === T) return i2;
let h2 = void 0 !== e2 ? (_a2 = s2._$Co) == null ? void 0 : _a2[e2] : s2._$Cl;
const o2 = c(i2) ? void 0 : i2._$litDirective$;
return (h2 == null ? void 0 : h2.constructor) !== o2 && ((_b = h2 == null ? void 0 : h2._$AO) == null ? void 0 : _b.call(h2, false), void 0 === o2 ? h2 = void 0 : (h2 = new o2(t2), h2._$AT(t2, s2, e2)), void 0 !== e2 ? (s2._$Co ?? (s2._$Co = []))[e2] = h2 : s2._$Cl = h2), void 0 !== h2 && (i2 = S(t2, h2._$AS(t2, i2.values), h2, e2)), i2;
}
class M {
constructor(t2, i2) {
this._$AV = [], this._$AN = void 0, this._$AD = t2, this._$AM = i2;
}
get parentNode() {
return this._$AM.parentNode;
}
get _$AU() {
return this._$AM._$AU;
}
u(t2) {
const { el: { content: i2 }, parts: s2 } = this._$AD, e2 = ((t2 == null ? void 0 : t2.creationScope) ?? r).importNode(i2, true);
C.currentNode = e2;
let h2 = C.nextNode(), o2 = 0, n3 = 0, l2 = s2[0];
for (; void 0 !== l2; ) {
if (o2 === l2.index) {
let i3;
2 === l2.type ? i3 = new R(h2, h2.nextSibling, this, t2) : 1 === l2.type ? i3 = new l2.ctor(h2, l2.name, l2.strings, this, t2) : 6 === l2.type && (i3 = new z(h2, this, t2)), this._$AV.push(i3), l2 = s2[++n3];
}
o2 !== (l2 == null ? void 0 : l2.index) && (h2 = C.nextNode(), o2++);
}
return C.currentNode = r, e2;
}
p(t2) {
let i2 = 0;
for (const s2 of this._$AV) void 0 !== s2 && (void 0 !== s2.strings ? (s2._$AI(t2, s2, i2), i2 += s2.strings.length - 2) : s2._$AI(t2[i2])), i2++;
}
}
class R {
get _$AU() {
var _a2;
return ((_a2 = this._$AM) == null ? void 0 : _a2._$AU) ?? this._$Cv;
}
constructor(t2, i2, s2, e2) {
this.type = 2, this._$AH = E, this._$AN = void 0, this._$AA = t2, this._$AB = i2, this._$AM = s2, this.options = e2, this._$Cv = (e2 == null ? void 0 : e2.isConnected) ?? true;
}
get parentNode() {
let t2 = this._$AA.parentNode;
const i2 = this._$AM;
return void 0 !== i2 && 11 === (t2 == null ? void 0 : t2.nodeType) && (t2 = i2.parentNode), t2;
}
get startNode() {
return this._$AA;
}
get endNode() {
return this._$AB;
}
_$AI(t2, i2 = this) {
t2 = S(this, t2, i2), c(t2) ? t2 === E || null == t2 || "" === t2 ? (this._$AH !== E && this._$AR(), this._$AH = E) : t2 !== this._$AH && t2 !== T && this._(t2) : void 0 !== t2._$litType$ ? this.$(t2) : void 0 !== t2.nodeType ? this.T(t2) : u(t2) ? this.k(t2) : this._(t2);
}
O(t2) {
return this._$AA.parentNode.insertBefore(t2, this._$AB);
}
T(t2) {
this._$AH !== t2 && (this._$AR(), this._$AH = this.O(t2));
}
_(t2) {
this._$AH !== E && c(this._$AH) ? this._$AA.nextSibling.data = t2 : this.T(r.createTextNode(t2)), this._$AH = t2;
}
$(t2) {
var _a2;
const { values: i2, _$litType$: s2 } = t2, e2 = "number" == typeof s2 ? this._$AC(t2) : (void 0 === s2.el && (s2.el = N.createElement(P(s2.h, s2.h[0]), this.options)), s2);
if (((_a2 = this._$AH) == null ? void 0 : _a2._$AD) === e2) this._$AH.p(i2);
else {
const t3 = new M(e2, this), s3 = t3.u(this.options);
t3.p(i2), this.T(s3), this._$AH = t3;
}
}
_$AC(t2) {
let i2 = A.get(t2.strings);
return void 0 === i2 && A.set(t2.strings, i2 = new N(t2)), i2;
}
k(t2) {
a(this._$AH) || (this._$AH = [], this._$AR());
const i2 = this._$AH;
let s2, e2 = 0;
for (const h2 of t2) e2 === i2.length ? i2.push(s2 = new R(this.O(l()), this.O(l()), this, this.options)) : s2 = i2[e2], s2._$AI(h2), e2++;
e2 < i2.length && (this._$AR(s2 && s2._$AB.nextSibling, e2), i2.length = e2);
}
_$AR(t2 = this._$AA.nextSibling, i2) {
var _a2;
for ((_a2 = this._$AP) == null ? void 0 : _a2.call(this, false, true, i2); t2 && t2 !== this._$AB; ) {
const i3 = t2.nextSibling;
t2.remove(), t2 = i3;
}
}
setConnected(t2) {
var _a2;
void 0 === this._$AM && (this._$Cv = t2, (_a2 = this._$AP) == null ? void 0 : _a2.call(this, t2));
}
}
class k {
get tagName() {
return this.element.tagName;
}
get _$AU() {
return this._$AM._$AU;
}
constructor(t2, i2, s2, e2, h2) {
this.type = 1, this._$AH = E, this._$AN = void 0, this.element = t2, this.name = i2, this._$AM = e2, this.options = h2, s2.length > 2 || "" !== s2[0] || "" !== s2[1] ? (this._$AH = Array(s2.length - 1).fill(new String()), this.strings = s2) : this._$AH = E;
}
_$AI(t2, i2 = this, s2, e2) {
const h2 = this.strings;
let o2 = false;
if (void 0 === h2) t2 = S(this, t2, i2, 0), o2 = !c(t2) || t2 !== this._$AH && t2 !== T, o2 && (this._$AH = t2);
else {
const e3 = t2;
let n3, r2;
for (t2 = h2[0], n3 = 0; n3 < h2.length - 1; n3++) r2 = S(this, e3[s2 + n3], i2, n3), r2 === T && (r2 = this._$AH[n3]), o2 || (o2 = !c(r2) || r2 !== this._$AH[n3]), r2 === E ? t2 = E : t2 !== E && (t2 += (r2 ?? "") + h2[n3 + 1]), this._$AH[n3] = r2;
}
o2 && !e2 && this.j(t2);
}
j(t2) {
t2 === E ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t2 ?? "");
}
}
class H extends k {
constructor() {
super(...arguments), this.type = 3;
}
j(t2) {
this.element[this.name] = t2 === E ? void 0 : t2;
}
}
class I extends k {
constructor() {
super(...arguments), this.type = 4;
}
j(t2) {
this.element.toggleAttribute(this.name, !!t2 && t2 !== E);
}
}
class L extends k {
constructor(t2, i2, s2, e2, h2) {
super(t2, i2, s2, e2, h2), this.type = 5;
}
_$AI(t2, i2 = this) {
if ((t2 = S(this, t2, i2, 0) ?? E) === T) return;
const s2 = this._$AH, e2 = t2 === E && s2 !== E || t2.capture !== s2.capture || t2.once !== s2.once || t2.passive !== s2.passive, h2 = t2 !== E && (s2 === E || e2);
e2 && this.element.removeEventListener(this.name, this, s2), h2 && this.element.addEventListener(this.name, this, t2), this._$AH = t2;
}
handleEvent(t2) {
var _a2;
"function" == typeof this._$AH ? this._$AH.call(((_a2 = this.options) == null ? void 0 : _a2.host) ?? this.element, t2) : this._$AH.handleEvent(t2);
}
}
class z {
constructor(t2, i2, s2) {
this.element = t2, this.type = 6, this._$AN = void 0, this._$AM = i2, this.options = s2;
}
get _$AU() {
return this._$AM._$AU;
}
_$AI(t2) {
S(this, t2);
}
}
const j = t.litHtmlPolyfillSupport;
j == null ? void 0 : j(N, R), (t.litHtmlVersions ?? (t.litHtmlVersions = [])).push("3.3.0");
const B = (t2, i2, s2) => {
const e2 = (s2 == null ? void 0 : s2.renderBefore) ?? i2;
let h2 = e2._$litPart$;
if (void 0 === h2) {
const t3 = (s2 == null ? void 0 : s2.renderBefore) ?? null;
e2._$litPart$ = h2 = new R(i2.insertBefore(l(), t3), t3, void 0, s2 ?? {});
}
return h2._$AI(t2), h2;
};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const s = globalThis;
class i extends y$1 {
constructor() {
super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0;
}
createRenderRoot() {
var _a2;
const t2 = super.createRenderRoot();
return (_a2 = this.renderOptions).renderBefore ?? (_a2.renderBefore = t2.firstChild), t2;
}
update(t2) {
const r2 = this.render();
this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t2), this._$Do = B(r2, this.renderRoot, this.renderOptions);
}
connectedCallback() {
var _a2;
super.connectedCallback(), (_a2 = this._$Do) == null ? void 0 : _a2.setConnected(true);
}
disconnectedCallback() {
var _a2;
super.disconnectedCallback(), (_a2 = this._$Do) == null ? void 0 : _a2.setConnected(false);
}
render() {
return T;
}
}
i._$litElement$ = true, i["finalized"] = true, (_a = s.litElementHydrateSupport) == null ? void 0 : _a.call(s, { LitElement: i });
const o = s.litElementPolyfillSupport;
o == null ? void 0 : o({ LitElement: i });
(s.litElementVersions ?? (s.litElementVersions = [])).push("4.2.0");
export {
E,
f$1 as f,
i,
u$1 as u,
x
};

View File

@@ -0,0 +1,47 @@
import { f, u } from "./lit-z6_uA4GX.mjs";
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const t = (t2) => (e, o2) => {
void 0 !== o2 ? o2.addInitializer(() => {
customElements.define(t2, e);
}) : customElements.define(t2, e);
};
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const o = { attribute: true, type: String, converter: u, reflect: false, hasChanged: f }, r = (t2 = o, e, r2) => {
const { kind: n2, metadata: i } = r2;
let s = globalThis.litPropertyMetadata.get(i);
if (void 0 === s && globalThis.litPropertyMetadata.set(i, s = /* @__PURE__ */ new Map()), "setter" === n2 && ((t2 = Object.create(t2)).wrapped = true), s.set(r2.name, t2), "accessor" === n2) {
const { name: o2 } = r2;
return { set(r3) {
const n3 = e.get.call(this);
e.set.call(this, r3), this.requestUpdate(o2, n3, t2);
}, init(e2) {
return void 0 !== e2 && this.C(o2, void 0, t2, e2), e2;
} };
}
if ("setter" === n2) {
const { name: o2 } = r2;
return function(r3) {
const n3 = this[o2];
e.call(this, r3), this.requestUpdate(o2, n3, t2);
};
}
throw Error("Unsupported decorator location: " + n2);
};
function n(t2) {
return (e, o2) => "object" == typeof o2 ? r(t2, e, o2) : ((t3, e2, o3) => {
const r2 = e2.hasOwnProperty(o3);
return e2.constructor.createProperty(o3, t3), r2 ? Object.getOwnPropertyDescriptor(e2, o3) : void 0;
})(t2, e, o2);
}
export {
n,
t
};

View File

@@ -0,0 +1,128 @@
import { E } from "./lit-z6_uA4GX.mjs";
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const f$1 = (o2) => void 0 === o2.strings;
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const t = { CHILD: 2 }, e$1 = (t2) => (...e2) => ({ _$litDirective$: t2, values: e2 });
class i {
constructor(t2) {
}
get _$AU() {
return this._$AM._$AU;
}
_$AT(t2, e2, i2) {
this._$Ct = t2, this._$AM = e2, this._$Ci = i2;
}
_$AS(t2, e2) {
return this.update(t2, e2);
}
update(t2, e2) {
return this.render(...e2);
}
}
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const s = (i2, t2) => {
var _a;
const e2 = i2._$AN;
if (void 0 === e2) return false;
for (const i3 of e2) (_a = i3._$AO) == null ? void 0 : _a.call(i3, t2, false), s(i3, t2);
return true;
}, o$1 = (i2) => {
let t2, e2;
do {
if (void 0 === (t2 = i2._$AM)) break;
e2 = t2._$AN, e2.delete(i2), i2 = t2;
} while (0 === (e2 == null ? void 0 : e2.size));
}, r = (i2) => {
for (let t2; t2 = i2._$AM; i2 = t2) {
let e2 = t2._$AN;
if (void 0 === e2) t2._$AN = e2 = /* @__PURE__ */ new Set();
else if (e2.has(i2)) break;
e2.add(i2), c(t2);
}
};
function h$1(i2) {
void 0 !== this._$AN ? (o$1(this), this._$AM = i2, r(this)) : this._$AM = i2;
}
function n$1(i2, t2 = false, e2 = 0) {
const r2 = this._$AH, h2 = this._$AN;
if (void 0 !== h2 && 0 !== h2.size) if (t2) if (Array.isArray(r2)) for (let i3 = e2; i3 < r2.length; i3++) s(r2[i3], false), o$1(r2[i3]);
else null != r2 && (s(r2, false), o$1(r2));
else s(this, i2);
}
const c = (i2) => {
i2.type == t.CHILD && (i2._$AP ?? (i2._$AP = n$1), i2._$AQ ?? (i2._$AQ = h$1));
};
class f extends i {
constructor() {
super(...arguments), this._$AN = void 0;
}
_$AT(i2, t2, e2) {
super._$AT(i2, t2, e2), r(this), this.isConnected = i2._$AU;
}
_$AO(i2, t2 = true) {
var _a, _b;
i2 !== this.isConnected && (this.isConnected = i2, i2 ? (_a = this.reconnected) == null ? void 0 : _a.call(this) : (_b = this.disconnected) == null ? void 0 : _b.call(this)), t2 && (s(this, i2), o$1(this));
}
setValue(t2) {
if (f$1(this._$Ct)) this._$Ct._$AI(t2, this);
else {
const i2 = [...this._$Ct._$AH];
i2[this._$Ci] = t2, this._$Ct._$AI(i2, this, 0);
}
}
disconnected() {
}
reconnected() {
}
}
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
const e = () => new h();
class h {
}
const o = /* @__PURE__ */ new WeakMap(), n = e$1(class extends f {
render(i2) {
return E;
}
update(i2, [s2]) {
var _a;
const e2 = s2 !== this.G;
return e2 && void 0 !== this.G && this.rt(void 0), (e2 || this.lt !== this.ct) && (this.G = s2, this.ht = (_a = i2.options) == null ? void 0 : _a.host, this.rt(this.ct = i2.element)), E;
}
rt(t2) {
if (this.isConnected || (t2 = void 0), "function" == typeof this.G) {
const i2 = this.ht ?? globalThis;
let s2 = o.get(i2);
void 0 === s2 && (s2 = /* @__PURE__ */ new WeakMap(), o.set(i2, s2)), void 0 !== s2.get(this.G) && this.G.call(this.ht, void 0), s2.set(this.G, t2), void 0 !== t2 && this.G.call(this.ht, t2);
} else this.G.value = t2;
}
get lt() {
var _a, _b;
return "function" == typeof this.G ? (_a = o.get(this.ht ?? globalThis)) == null ? void 0 : _a.get(this.G) : (_b = this.G) == null ? void 0 : _b.value;
}
disconnected() {
this.lt === this.ct && this.rt(void 0);
}
reconnected() {
this.rt(this.ct);
}
});
export {
e,
n
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,24 @@
:root {
--background-color: #FFF;
--background-darker: #EEE;
--primary-color: #2F2FE1;
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF;
/* --color-red: #FE2060; */
/* --color-red: #EE1D59; */
--color-red: #E31B39;
}
html {
background-color: var(--background-color);
}
body {
/* position: relative; */
font-family: sans-serif;
margin: 0 auto;
max-width: 1200px;
min-height: 100%;
}
* {
@@ -48,25 +65,11 @@ header {
align-items: center;
}
:root {
--background-color: #FFF;
--background-darker: #EEE;
--primary-color: #2F2FE1;
--primary-color-dark: color-mix(in srgb, var(--primary-color), #000000 80%);
--text-on-primary-color: #FFF;
/* --color-red: #FE2060; */
--color-red: #EE1D59;
}
html {
background-color: var(--background-color);
}
button,
.button {
border: none;
background: var(--primary-color);
padding: 8px 12px;
padding: 10px 16px;
border-radius: 8px;
color: var(--text-on-primary-color);
font-size: 0.9em;
@@ -75,7 +78,8 @@ button,
filter: brightness(90%);
}
&.delete {
&.delete,
&.cancel {
background: var(--color-red);
}
}
@@ -109,13 +113,13 @@ table {
box-shadow: 4px 2px 12px -6px black;
border-collapse: collapse;
overflow-x: scroll;
display: block;
width: fit-content;
width: 100%;
td,
th {
padding: 8px;
border: 1px solid black;
width: max-content;
}
th {
@@ -160,14 +164,15 @@ table {
display: grid;
min-height: 80px;
grid-template-areas:
". . color-chip"
"title comps color-chip"
"description . color-chip"
"subscription-url . color-chip"
"actions . color-chip"
". . color-chip";
". . color-chip"
"title comps color-chip"
"description description color-chip"
"subscription-url subscription-url color-chip"
"actions actions color-chip"
". . color-chip";
grid-template-rows: 12px auto auto auto auto 12px;
grid-template-columns: min-content auto 80px;
row-gap: 4px;
color: inherit;
text-decoration: none;
padding-left: 12px;
@@ -220,6 +225,8 @@ table {
.actions {
grid-area: actions;
width: fit-content;
display: flex;
gap: 12px;
}
&:hover {
@@ -233,3 +240,18 @@ table {
textarea {
width: 100%;
}
dialog {
border: 1px solid black;
border-radius: 16px;
padding: 32px;
}
footer {
display: flex;
justify-content: center;
margin-top: 32px;
gap: 24px;
/* position: absolute; */
bottom: 20px;
}

View File

@@ -0,0 +1,56 @@
<section>
<h2>Addressbooks</h2>
<ul>
{% for addressbook in addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}" target="_blank"
method="GET">
<button type="submit">Download</button>
</form>
<delete-button trash
href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% else %}
You do not have any addressbooks yet
{% endfor %}
</ul>
{%if !deleted_addressbooks.is_empty() %}
<h3>Deleted Addressbooks</h3>
<ul>
{% for addressbook in deleted_addressbooks %}
<li class="collection-list-item">
<a href="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}">
<span class="title">
{%- if addressbook.principal != user.id -%}{{ addressbook.principal }}/{%- endif -%}
{{ addressbook.displayname.to_owned().unwrap_or(addressbook.id.to_owned()) }}
</span>
<span class="description">
{% if let Some(description) = addressbook.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ addressbook.principal }}/addressbook/{{ addressbook.id}}/restore"
method="POST" class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/carddav/principal/{{ addressbook.principal }}/{{ addressbook.id }}"></delete-button>
</div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-addressbook-form user="{{ user.id }}"></create-addressbook-form>
</section>

View File

@@ -0,0 +1,72 @@
<section>
<h2>Calendars</h2>
<ul>
{% for calendar in calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id }}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
{% if let Some(subscription_url) = calendar.subscription_url %}
<span class="subscription-url">{{ subscription_url }}</span>
{% endif %}
<div class="actions">
<form action="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}" target="_blank" method="GET">
<button type="submit">Download</button>
</form>
{% if !calendar.id.starts_with("_birthdays_") %}
<delete-button trash href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
{% endif %}
</div>
<div class="color-chip"></div>
</a>
</li>
{% else %}
You do not have any calendars yet
{% endfor %}
</ul>
{%if !deleted_calendars.is_empty() %}
<h3>Deleted Calendars</h3>
<ul>
{% for calendar in deleted_calendars %}
{% let color = calendar.color.to_owned().unwrap_or("transparent".to_owned()) %}
<li class="collection-list-item" style="--color: {{ color }}">
<a href="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}">
<span class="title">
{%- if calendar.principal != user.id -%}{{ calendar.principal }}/{%- endif -%}
{{ calendar.displayname.to_owned().unwrap_or(calendar.id.to_owned()) }}
</span>
<div class="comps">
{% for comp in calendar.components %}
<span>{{ comp }}</span>
{% endfor %}
</div>
<span class="description">
{% if let Some(description) = calendar.description %}{{ description }}{% endif %}
</span>
<div class="actions">
<form action="/frontend/user/{{ calendar.principal }}/calendar/{{ calendar.id}}/restore" method="POST"
class="restore-form">
<button type="submit">Restore</button>
</form>
<delete-button href="/caldav/principal/{{ calendar.principal }}/{{ calendar.id }}"></delete-button>
</div>
<div class="color-chip"></div>
</a>
</li>
{% endfor %}
</ul>
{% endif %}
<create-calendar-form user="{{ user.id }}"></create-calendar-form>
</section>

View File

@@ -0,0 +1,58 @@
<section>
<h2>Profile</h2>
{% let groups = user.memberships_without_self() %}
{% if groups.len() > 0 %}
<h3>Groups</h3>
<ul>
{% for group in groups %}
<li>{{ group }}</li>
{% endfor %}
</ul>
{% endif %}
<h3>App tokens</h3>
<table id="app-tokens">
<tr>
<th>Name</th>
<th>Created at</th>
<th></th>
</tr>
{% for app_token in app_tokens %}
<tr>
<td>{{ app_token.name }}</td>
<td>
{% if let Some(created_at) = app_token.created_at %}
{{ chrono_humanize::HumanTime::from(created_at.to_owned()) }}
{% endif %}
</td>
<td>
<form action="/frontend/user/{{ user.id }}/app_token/{{ app_token.id }}/delete" method="POST">
<button type="submit" class="delete">Delete</button>
</form>
</td>
</tr>
{% endfor %}
<tr class="generate">
<td>
<form action="/frontend/user/{{ user.id }}/app_token" method="POST" id="form_generate_app_token">
<label class="font_bold" for="generate_app_token_name">App name</label>
<input type="text" name="name" id="generate_app_token_name" />
</form>
</td>
<td></td>
<td>
<button type="submit" form="form_generate_app_token">Generate</button>
{% if is_apple %}
<button type="submit" form="form_generate_app_token" name="apple" value="true">Apple Configuration Profile
(contains token)</button>
{% endif %}
</td>
</tr>
</table>
{% if let Some(hostname) = davx5_hostname %}
<a
href="intent://{{ hostname | urlencode }}#Intent;action=android.intent.action.VIEW;component=at.bitfire.davdroid.ui.setup.LoginActivity;scheme=davx5;package=at.bitfire.davdroid;S.loginFlow=1;end">Configure
in DAVx5</a>
{% endif %}
</section>

View File

@@ -23,6 +23,7 @@
</div>
</body>
<footer>
<a href="{{ env!("CARGO_PKG_REPOSITORY") }}" target="_blank">RustiCal {{ env!("CARGO_PKG_VERSION") }}</a>
<a href="/frontend/assets/licenses.html" target="_blank">Open Source Licenses</a>
</footer>
</html>

View File

@@ -10,12 +10,4 @@
<pre>{{ addressbook|json }}</pre>
<h2>Delete</h2>
<section>
<form method="POST" action="/frontend/user/{{addressbook.principal}}/addressbook/{{addressbook.id}}/delete">
<button type="submit">Move to trash</button>
</form>
</section>
{% endblock %}

View File

@@ -31,12 +31,4 @@
<pre>{{ calendar|json }}</pre>
<h2>Delete</h2>
<section>
<form method="POST" action="/frontend/user/{{calendar.principal}}/calendar/{{calendar.id}}/delete">
<button type="submit">Move to trash</button>
</form>
</section>
{%endblock %}

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